diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 3904eb9ec..24a3955e7 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -69,6 +69,7 @@
+
@@ -78,6 +79,7 @@
+
@@ -212,4 +214,4 @@
-
\ 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/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 3ee1c8d54..34496df0f 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"
},
@@ -79,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."
}
}
},
@@ -257,6 +270,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": {
@@ -286,6 +309,9 @@
"recent_search": "Recent searches",
"clear": "clear"
}
+ },
+ "hashtag": {
+ "prompt": "%s people talking"
}
}
-}
+}
\ No newline at end of file
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index 214aece57..97b65eeb1 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -7,6 +7,16 @@
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 */; };
+ 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 */; };
@@ -149,7 +159,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,7 +269,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 */; };
@@ -274,6 +283,14 @@
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 */; };
+ 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 */; };
@@ -302,6 +319,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 */
@@ -360,6 +379,16 @@
/* 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 = ""; };
+ 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 = ""; };
@@ -503,7 +532,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 = ""; };
@@ -634,6 +663,14 @@
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 = ""; };
+ 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 = ""; };
@@ -661,6 +698,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 = ""; };
@@ -722,6 +761,29 @@
/* 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 */,
+ 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */,
+ 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
+ 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */,
+ 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */,
+ );
+ path = HashtagTimeline;
+ sourceTree = "";
+ };
0FAA0FDD25E0B5700017CCDE /* Welcome */ = {
isa = PBXGroup;
children = (
@@ -794,6 +856,7 @@
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */,
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */,
DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
+ DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */,
);
path = Content;
sourceTree = "";
@@ -916,6 +979,7 @@
isa = PBXGroup;
children = (
2D38F1FC25CD47D900561493 /* StatusProvider */,
+ DBAE3F742615DD63004B8251 /* UserProvider */,
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */,
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */,
@@ -999,6 +1063,7 @@
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
+ DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */,
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */,
);
path = TableviewCell;
@@ -1241,7 +1306,11 @@
DB9A488F26035963008B817C /* APIService+Media.swift */,
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
+ 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */,
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
+ DBAE3F932616E28B004B8251 /* APIService+Follow.swift */,
+ DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
+ DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
);
path = APIService;
sourceTree = "";
@@ -1456,6 +1525,7 @@
DB8AF55525C1379F002E6C99 /* Scene */ = {
isa = PBXGroup;
children = (
+ 0F2021F5261325ED000C64BF /* HashtagTimeline */,
5D03938E2612D200007FE196 /* Webview */,
2D7631A425C1532200929FB9 /* Share */,
DB8AF54E25C13703002E6C99 /* MainTab */,
@@ -1500,6 +1570,7 @@
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
2D84350425FF858100EECE90 /* UIScrollView.swift */,
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
+ 0F20223826146553000C64BF /* Array+removeDuplicates.swift */,
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
);
@@ -1564,8 +1635,10 @@
DBB525462611ED57002F1F29 /* Header */,
DBB5253B2611ECF5002F1F29 /* Timeline */,
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
+ DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */,
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */,
+ DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */,
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */,
);
path = Profile;
@@ -1598,6 +1671,7 @@
children = (
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */,
DB35FC2E26130172006193C9 /* MastodonField.swift */,
+ DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */,
);
path = Helper;
sourceTree = "";
@@ -1619,6 +1693,15 @@
path = View;
sourceTree = "";
};
+ DBAE3F742615DD63004B8251 /* UserProvider */ = {
+ isa = PBXGroup;
+ children = (
+ DBAE3F672615DD60004B8251 /* UserProvider.swift */,
+ DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */,
+ );
+ path = UserProvider;
+ sourceTree = "";
+ };
DBB525132611EBB1002F1F29 /* Segmented */ = {
isa = PBXGroup;
children = (
@@ -1664,7 +1747,7 @@
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */,
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
- DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */,
+ DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
);
path = View;
@@ -2051,6 +2134,8 @@
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 */,
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
@@ -2085,12 +2170,14 @@
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
DBB5255E2611F07A002F1F29 /* ProfileViewModel.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 */,
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
+ DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
@@ -2111,6 +2198,7 @@
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
2D19864F261C372A00F0B013 /* SearchBottomLoader.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 */,
@@ -2121,6 +2209,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 */,
@@ -2133,7 +2222,9 @@
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 */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
@@ -2167,17 +2258,19 @@
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.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 */,
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 */,
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 */,
@@ -2188,7 +2281,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 */,
@@ -2200,6 +2292,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 */,
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */,
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
@@ -2213,6 +2306,7 @@
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
+ 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
@@ -2225,12 +2319,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 */,
@@ -2243,6 +2339,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 */,
@@ -2250,7 +2347,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 /* MastodonStatusContent.swift in Sources */,
@@ -2260,11 +2359,13 @@
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
+ 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */,
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 */,
+ 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
@@ -2291,6 +2392,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.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/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift
index d578ee528..c76c8ba47 100644
--- a/Mastodon/Coordinator/SceneCoordinator.swift
+++ b/Mastodon/Coordinator/SceneCoordinator.swift
@@ -51,11 +51,15 @@ extension SceneCoordinator {
// compose
case compose(viewModel: ComposeViewModel)
+ // Hashtag Timeline
+ case hashtagTimeline(viewModel: HashtagTimelineViewModel)
+
// profile
case profile(viewModel: ProfileViewModel)
// misc
case alertController(alertController: UIAlertController)
+ case safari(url: URL)
#if DEBUG
case publicTimeline
@@ -111,6 +115,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.navigationItem.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,
@@ -209,6 +224,10 @@ private extension SceneCoordinator {
let _viewController = ComposeViewController()
_viewController.viewModel = viewModel
viewController = _viewController
+ case .hashtagTimeline(let viewModel):
+ let _viewController = HashtagTimelineViewController()
+ _viewController.viewModel = viewModel
+ viewController = _viewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
@@ -222,6 +241,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/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/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/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift
index e9785461a..56aa32798 100644
--- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift
+++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift
@@ -22,6 +22,8 @@ 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/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/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/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/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/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
}
}
diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift
index 8276cfb20..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,26 +73,21 @@ 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 {
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 05386714e..864502379 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
@@ -150,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")
@@ -267,6 +299,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")
@@ -290,6 +328,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/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/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/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..d1c24c97f 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
@@ -62,6 +62,69 @@ extension StatusProviderFacade {
}
}
+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)
+ provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: provider, transition: .show)
+ 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 }
+ 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/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/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/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/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/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/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/Colors/backgroundLight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json
similarity index 65%
rename from Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json
index 0e4687fb4..473d42adc 100644
--- a/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json
+++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json
@@ -4,10 +4,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
- "alpha" : "1.000",
- "blue" : "0.910",
- "green" : "0.882",
- "red" : "0.851"
+ "alpha" : "0.600",
+ "blue" : "0.961",
+ "green" : "0.922",
+ "red" : "0.922"
}
},
"idiom" : "universal"
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 662491b2e..67cb5de00 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";
@@ -47,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";
@@ -88,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.Hashtag.Prompt" = "%@ people talking";
"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts";
"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline";
"Scene.HomeTimeline.NavigationBarState.Published" = "Published!";
@@ -96,6 +111,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/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..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 .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 52ca4cc88..f52c38a17 100644
--- a/Mastodon/Scene/Compose/ComposeViewModel.swift
+++ b/Mastodon/Scene/Compose/ComposeViewModel.swift
@@ -56,6 +56,10 @@ final class ComposeViewModel {
let isPollToolbarButtonEnabled = CurrentValueSubject(true)
let characterCount = CurrentValueSubject(0)
+ // for hashtag: #' '
+ // for mention: @' '
+ private(set) var preInsertedContent: String?
+
// custom emojis
var customEmojiViewModelSubscription: AnyCancellable?
let customEmojiViewModel = CurrentValueSubject(nil)
@@ -76,12 +80,30 @@ final class ComposeViewModel {
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, .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 .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)
+ let preInsertedContent = initialComposeContent + " "
+ self.preInsertedContent = preInsertedContent
+ self.composeStatusAttribute.composeContent.value = preInsertedContent
+ }
+ } else {
+ self.preInsertedContent = nil
+ }
isCustomEmojiComposing
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
@@ -195,9 +217,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 preInsertedContent plus a space is equal to the content, simply dismiss the modal
+ if let preInsertedContent = self?.preInsertedContent {
+ return content == (preInsertedContent + " ")
+ }
+ return false
}
.assign(to: \.value, on: shouldDismiss)
.store(in: &disposeBag)
@@ -304,6 +333,11 @@ final class ComposeViewModel {
self.isPollToolbarButtonEnabled.value = !shouldPollDisable
})
.store(in: &disposeBag)
+
+ if let preInsertedContent = preInsertedContent {
+ // add a space after the injected text
+ composeStatusAttribute.composeContent.send(preInsertedContent + " ")
+ }
}
deinit {
diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift
new file mode 100644
index 000000000..23068b7bc
--- /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 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.context.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.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..cefd7b238
--- /dev/null
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift
@@ -0,0 +1,317 @@
+//
+// 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()
+
+ let titleView = HashtagTimelineNavigationBarTitleView()
+
+ 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)"
+ titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil)
+ navigationItem.titleView = titleView
+
+ 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)
+
+ 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 }
+
+ 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()
+ }
+ }
+
+ private func updatePromptTitle() {
+ var subtitle: String?
+ defer {
+ titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle)
+ }
+ guard let histories = viewModel.hashtagEntity.value?.history else {
+ return
+ }
+ if histories.isEmpty {
+ // No tag history, remove the prompt title
+ return
+ } else {
+ let sortedHistory = histories.sorted { (h1, h2) -> Bool in
+ return h1.day > h2.day
+ }
+ let peopleTalkingNumber = sortedHistory
+ .prefix(2)
+ .compactMap({ Int($0.accounts) })
+ .reduce(0, +)
+ subtitle = "\(peopleTalkingNumber)"
+ }
+ }
+}
+
+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: .hashtag(hashtag: 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, upperTimelineStatusID: 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..26f32a33c
--- /dev/null
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift
@@ -0,0 +1,124 @@
+//
+// 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: - 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 }
+ guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
+
+ guard let diffableDataSource = self.diffableDataSource else { return }
+
+ let parentManagedObjectContext = fetchedResultsController.fetchedResultsController.managedObjectContext
+ let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+ managedObjectContext.parent = parentManagedObjectContext
+
+ 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] = newObjectIDs.map {
+ let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute()
+ return Item.status(objectID: $0, attribute: attribute)
+ }
+
+ 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: newObjectIDs[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 }
+ guard let item = oldSnapshot.itemIdentifiers.first as? Item, case Item.status = item 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..b2d121d50
--- /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 statues 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
+ 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
+ }
+
+ oldStatusIDs.insert(contentsOf: newStatusIDList, at: 0)
+ let newIDs = oldStatusIDs.removingDuplicates()
+
+ viewModel.fetchedResultsController.statusIDs.value = newIDs
+ }
+ .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..dcd3f81ac
--- /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.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else {
+ stateMachine.enter(Fail.self)
+ return
+ }
+ let statusIDs = (viewModel.fetchedResultsController.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 statuses 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 }
+
+ 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,
+ !oldStatusIDs.contains(lastNewStatusID) {
+ viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count
+ } else {
+ viewModel.needLoadMiddleIndex = nil
+ }
+ 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
+ }
+
+ viewModel.fetchedResultsController.statusIDs.value = oldStatusIDs
+
+ }
+ .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..d0607550e
--- /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.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.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 statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
+ case .finished:
+ // handle isFetchingLatestTimeline in fetch controller delegate
+ break
+ }
+ } receiveValue: { response in
+ 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)
+ }
+ 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)
+ }
+ }
+
+ 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..b43b67143
--- /dev/null
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift
@@ -0,0 +1,105 @@
+//
+// 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 needLoadMiddleIndex: Int? = nil
+
+ // input
+ let context: AppContext
+ let fetchedResultsController: StatusFetchedResultsController
+ let isFetchingLatestTimeline = CurrentValueSubject(false)
+ let timelinePredicate = CurrentValueSubject(nil)
+ let hashtagEntity = 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
+ let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value
+ self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil)
+ super.init()
+
+ fetchedResultsController.objectIDs
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] objectIds in
+ self?.generateStatusItems(newObjectIDs: objectIds)
+ }
+ .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..78d5a971c
--- /dev/null
+++ b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift
@@ -0,0 +1,71 @@
+//
+// HashtagTimelineTitleView.swift
+// Mastodon
+//
+// Created by BradGao on 2021/4/1.
+//
+
+import UIKit
+
+final class HashtagTimelineNavigationBarTitleView: UIView {
+
+ let containerView = UIStackView()
+
+ 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
+ }()
+
+ 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)
+ _init()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ _init()
+ }
+
+}
+
+extension HashtagTimelineNavigationBarTitleView {
+ private func _init() {
+ containerView.axis = .vertical
+ containerView.alignment = .center
+ containerView.distribution = .fill
+ 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(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
+ }
+ }
+}
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/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/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..bf292ac45 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,19 @@ 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.backgroundColor = 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 +70,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 +118,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 +180,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 +208,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..0f6a804b5
--- /dev/null
+++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift
@@ -0,0 +1,46 @@
+//
+// 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), for: .disabled)
+
+ if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked {
+ isEnabled = false
+ } else {
+ isEnabled = true
+ }
+ }
+}
+
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..59cf4809f 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,82 +101,56 @@ 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.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)
-
+ 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)
+
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(
@@ -244,23 +224,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.navigationItem.title = name
+ }
+ .store(in: &disposeBag)
+
Publishers.CombineLatest(
viewModel.bannerImageURL.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
@@ -268,56 +240,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 +270,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
@@ -482,6 +380,27 @@ 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) {
+ 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 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) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@@ -500,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)
-// }
}
@@ -600,64 +482,99 @@ 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) {
-
+
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) {
diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift
index f7248009d..057e18030 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 blocked
+ case blocking
+ 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 blocked = RelationshipAction.blocked.option
+ static let blocking = RelationshipAction.blocking.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 .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
+ }
+ }
+
+ 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 .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
+ }
+ }
+
+ }
}
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/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..f31f52400 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,23 @@ 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?) {
+ super.didEnter(from: previousState)
+ guard let viewModel = viewModel, let _ = stateMachine 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/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
index ba4babc52..b4305eeff 100644
--- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
+++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
@@ -70,7 +70,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
extension SearchRecommendAccountsCollectionViewCell {
private func configure() {
- headerImageView.backgroundColor = Asset.Colors.buttonDefault.color
+ headerImageView.backgroundColor = Asset.Colors.brandBlue.color
layer.cornerRadius = 8
clipsToBounds = true
diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
index 4fe87e3f8..08090f804 100644
--- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
+++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
@@ -59,7 +59,7 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
extension SearchRecommendTagsCollectionViewCell {
private func configure() {
- backgroundColor = Asset.Colors.buttonDefault.color
+ backgroundColor = Asset.Colors.brandBlue.color
layer.cornerRadius = 8
clipsToBounds = true
diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift
index 9a8ab4804..2e6fc651c 100644
--- a/Mastodon/Scene/Search/SearchViewController.swift
+++ b/Mastodon/Scene/Search/SearchViewController.swift
@@ -20,7 +20,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/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
index ebd60ac30..df876a635 100644
--- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
+++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
@@ -27,8 +27,8 @@ 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.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
+ button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal)
return button
}()
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/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/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/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
index 9c954e505..b600924a6 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,19 +19,23 @@ 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)
+ func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath)
}
extension StatusTableViewCellDelegate {
@@ -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/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/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)
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()
diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift
new file mode 100644
index 000000000..124b65155
--- /dev/null
+++ b/Mastodon/Service/APIService/APIService+Block.swift
@@ -0,0 +1,168 @@
+//
+// 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 else { return }
+ switch completion {
+ case .failure(let error):
+ // TODO: handle error
+ 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 {
+ 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..f2c57db57
--- /dev/null
+++ b/Mastodon/Service/APIService/APIService+Follow.swift
@@ -0,0 +1,188 @@
+//
+// 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 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
+ }
+ }
+ })
+ .eraseToAnyPublisher()
+ }
+
+}
diff --git a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift
new file mode 100644
index 000000000..69c2c7486
--- /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 = onceRequestStatusMaxCount,
+ 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/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift
new file mode 100644
index 000000000..9d992ab6a
--- /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 else { return }
+ switch completion {
+ case .failure(let error):
+ // TODO: handle error
+ 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 {
+ 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()
+ }
+
+}
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift
index 3b01c2c13..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,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 d4b8e8045..dc6ef71e7 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift
@@ -49,9 +49,23 @@ extension Mastodon.API.Search {
}
}
-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?) {
+extension Mastodon.API.Search {
+ public enum SearchType: String, Codable {
+ case ccounts, hashtags, statuses
+ }
+
+ public struct Query: Codable, GetQuery {
+ 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 +81,7 @@ public 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 +94,7 @@ public 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 03a718b5b..c1857ae82 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("timelines/tag/\(hashtag)")
+ }
/// View public timeline statuses
///
@@ -81,16 +85,50 @@ 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.
+ /// - 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,
+ authorization: Mastodon.API.OAuth.Authorization?
+ ) -> AnyPublisher, Error> {
+ let request = Mastodon.API.get(
+ url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag),
+ 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 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 {
@@ -167,4 +205,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
+ }
+ }
+
}
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
index 376dbeb36..2fdb9b346 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
@@ -114,6 +114,7 @@ extension Mastodon.API {
public enum Search { }
public enum Trends { }
public enum Suggestions { }
+ public enum Notifications { }
}
extension Mastodon.API {