diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index e2f059d50..9e5b7cf39 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -82,6 +82,7 @@
+
@@ -199,7 +200,7 @@
-
+
diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift
index 878eb9ad4..714b6d0f6 100644
--- a/CoreDataStack/Entity/MastodonUser.swift
+++ b/CoreDataStack/Entity/MastodonUser.swift
@@ -31,6 +31,7 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var locked: Bool
@NSManaged public private(set) var bot: Bool
+ @NSManaged public private(set) var suspended: Bool
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
@@ -93,6 +94,7 @@ extension MastodonUser {
user.locked = property.locked
user.bot = property.bot ?? false
+ user.suspended = property.suspended ?? false
// Mastodon do not provide relationship on the `Account`
// Update relationship via attribute updating interface
@@ -174,6 +176,11 @@ extension MastodonUser {
self.bot = bot
}
}
+ public func update(suspended: Bool) {
+ if self.suspended != suspended {
+ self.suspended = suspended
+ }
+ }
public func update(isFollowing: Bool, by mastodonUser: MastodonUser) {
if isFollowing {
@@ -268,6 +275,7 @@ extension MastodonUser {
public let followersCount: Int
public let locked: Bool
public let bot: Bool?
+ public let suspended: Bool?
public let createdAt: Date
public let networkDate: Date
@@ -289,6 +297,7 @@ extension MastodonUser {
followersCount: Int,
locked: Bool,
bot: Bool?,
+ suspended: Bool?,
createdAt: Date,
networkDate: Date
) {
@@ -309,6 +318,7 @@ extension MastodonUser {
self.followersCount = followersCount
self.locked = locked
self.bot = bot
+ self.suspended = suspended
self.createdAt = createdAt
self.networkDate = networkDate
}
diff --git a/Localization/app.json b/Localization/app.json
index d571d96fb..f29c588c2 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -44,6 +44,8 @@
"sign_up": "Sign Up",
"see_more": "See More",
"preview": "Preview",
+ "share": "Share",
+ "share_user": "Share %s",
"open_in_safari": "Open in Safari"
},
"status": {
@@ -69,9 +71,11 @@
"firendship": {
"follow": "Follow",
"following": "Following",
+ "request": "Request",
"pending": "Pending",
"block": "Block",
"block_user": "Block %s",
+ "block_domain": "Block %s",
"unblock": "Unblock",
"unblock_user": "Unblock %s",
"blocked": "Blocked",
@@ -91,7 +95,8 @@
"no_status_found": "No Status Found",
"blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.",
"blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.",
- "suspended_warning": "This account is suspended."
+ "suspended_warning": "This account has been suspended.",
+ "user_suspended_warning": "%s's account has been suspended."
}
}
},
@@ -217,7 +222,7 @@
"new_posts": "See new posts",
"published": "Published!",
"Publishing": "Publishing post..."
- },
+ }
},
"public_timeline": {
"title": "Public"
@@ -261,6 +266,7 @@
}
},
"profile": {
+ "subtitle": "%s posts",
"dashboard": {
"posts": "posts",
"following": "following",
@@ -288,21 +294,24 @@
"cancel": "Cancel"
},
"recommend": {
- "buttonText": "See All",
- "hash_tag": {
- "title": "Trending in your timeline",
- "description": "Hashtags that are getting quite a bit of attention among people you follow",
- "people_talking": "%s people are talking"
- },
- "accounts": {
- "title": "Accounts you might like",
- "description": "Except for Sam, you will not like his account.",
- "follow": "Follow"
- }
+ "buttonText": "See All",
+ "hash_tag": {
+ "title": "Trending in your timeline",
+ "description": "Hashtags that are getting quite a bit of attention among people you follow",
+ "people_talking": "%s people are talking"
+ },
+ "accounts": {
+ "title": "Accounts you might like",
+ "description": "Except for Sam, you will not like his account.",
+ "follow": "Follow"
+ }
}
},
"hashtag": {
"prompt": "%s people talking"
+ },
+ "favorite": {
+ "title": "Your Favorites"
}
}
}
\ No newline at end of file
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index 316cb4104..01b40a9c1 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -7,7 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
- 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; };
+ 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */; };
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; };
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; };
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; };
@@ -219,10 +219,12 @@
DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; };
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; };
+ DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; };
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; };
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; };
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; };
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; };
+ DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; };
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; };
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; };
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; };
@@ -302,7 +304,6 @@
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; };
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; };
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; };
- DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B7A261443AD0045B23D /* ViewController.swift */; };
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; };
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; };
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; };
@@ -313,6 +314,12 @@
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; };
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; };
+ DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */; };
+ DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */; };
+ DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */; };
+ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; };
+ DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; };
+ DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; };
DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; };
DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
/* End PBXBuildFile section */
@@ -371,7 +378,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
- 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; };
+ 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTitleLabelNavigationBarTitleView.swift; sourceTree = ""; };
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; };
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; };
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; };
@@ -589,10 +596,12 @@
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; };
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; };
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; };
+ DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; };
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; };
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; };
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; };
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; };
+ DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; };
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; };
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; };
DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; };
@@ -673,7 +682,6 @@
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = ""; };
DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = ""; };
DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = ""; };
- DBCC3B7A261443AD0045B23D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = ""; };
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = ""; };
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = ""; };
@@ -684,6 +692,12 @@
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; };
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; };
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = ""; };
+ DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewController.swift; sourceTree = ""; };
+ DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; };
+ DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+State.swift"; sourceTree = ""; };
+ DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = ""; };
+ DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = ""; };
+ DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; };
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; };
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; };
EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; };
@@ -745,23 +759,14 @@
/* 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 */,
+ 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */,
- 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */,
0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */,
);
@@ -841,6 +846,7 @@
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */,
DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */,
+ 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */,
);
path = Content;
sourceTree = "";
@@ -971,6 +977,7 @@
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */,
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */,
+ DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */,
);
path = Protocol;
sourceTree = "";
@@ -1137,8 +1144,8 @@
DB084B5125CBC56300F898ED /* CoreDataStack */ = {
isa = PBXGroup;
children = (
- DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
DB084B5625CBC56C00F898ED /* Status.swift */,
+ DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
DB9D6C3725E508BE0051B173 /* Attachment.swift */,
);
path = CoreDataStack;
@@ -1219,7 +1226,6 @@
children = (
DB427DE325BAA00100D1B89D /* Info.plist */,
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
- DBCC3B7A261443AD0045B23D /* ViewController.swift */,
2D76319C25C151DE00929FB9 /* Diffiable */,
DB8AF52A25C13561002E6C99 /* State */,
2D61335525C1886800CAE157 /* Service */,
@@ -1228,6 +1234,7 @@
DB9E0D6925EDFFE500CFDD76 /* Helper */,
DB8AF56225C138BC002E6C99 /* Extension */,
2D5A3D0125CF8640002347D6 /* Vender */,
+ DB73B495261F030D002E9E9F /* Activity */,
DB5086CB25CC0DB400C2C187 /* Preference */,
2D69CFF225CA9E2200C3A1B2 /* Protocol */,
DB98338425C945ED00AD9700 /* Generated */,
@@ -1367,6 +1374,14 @@
path = ServerRules;
sourceTree = "";
};
+ DB73B495261F030D002E9E9F /* Activity */ = {
+ isa = PBXGroup;
+ children = (
+ DB73B48F261F030A002E9E9F /* SafariActivity.swift */,
+ );
+ path = Activity;
+ sourceTree = "";
+ };
DB789A1025F9F29B0071ACA0 /* Compose */ = {
isa = PBXGroup;
children = (
@@ -1497,13 +1512,13 @@
DB8AF55525C1379F002E6C99 /* Scene */ = {
isa = PBXGroup;
children = (
- 0F2021F5261325ED000C64BF /* HashtagTimeline */,
5D03938E2612D200007FE196 /* Webview */,
2D7631A425C1532200929FB9 /* Share */,
DB8AF54E25C13703002E6C99 /* MainTab */,
DB01409B25C40BB600F9F3CF /* Onboarding */,
2D38F1D325CD463600561493 /* HomeTimeline */,
2D76316325C14BAC00929FB9 /* PublicTimeline */,
+ 0F2021F5261325ED000C64BF /* HashtagTimeline */,
DB9D6BEE25E4F5370051B173 /* Search */,
DB9D6BFD25E4F57B0051B173 /* Notification */,
DB9D6C0825E4F5A60051B173 /* Profile */,
@@ -1602,6 +1617,7 @@
DBB525132611EBB1002F1F29 /* Segmented */,
DBB525462611ED57002F1F29 /* Header */,
DBB5253B2611ECF5002F1F29 /* Timeline */,
+ DBE3CDF1261C6B3100430CC6 /* Favorite */,
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */,
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
@@ -1705,6 +1721,7 @@
children = (
DBB525732612D5A5002F1F29 /* View */,
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
+ DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */,
);
path = Header;
sourceTree = "";
@@ -1739,6 +1756,18 @@
path = Register;
sourceTree = "";
};
+ DBE3CDF1261C6B3100430CC6 /* Favorite */ = {
+ isa = PBXGroup;
+ children = (
+ DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */,
+ DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */,
+ DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */,
+ DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */,
+ DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */,
+ );
+ path = Favorite;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -2137,7 +2166,7 @@
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
- 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */,
+ 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
@@ -2158,6 +2187,7 @@
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
+ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
@@ -2172,6 +2202,7 @@
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
+ DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
@@ -2181,21 +2212,25 @@
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
+ DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */,
+ DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */,
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
+ DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
+ DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
@@ -2213,12 +2248,12 @@
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
- DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */,
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
+ DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */,
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */,
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
@@ -2353,6 +2388,7 @@
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
+ DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */,
DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
index c1f4dcf1f..6ec23cf5d 100644
--- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,7 +7,7 @@
CoreDataStack.xcscheme_^#shared#^_
orderHint
- 12
+ 10
Mastodon - RTL.xcscheme_^#shared#^_
diff --git a/Mastodon/Activity/SafariActivity.swift b/Mastodon/Activity/SafariActivity.swift
new file mode 100644
index 000000000..e10a0b082
--- /dev/null
+++ b/Mastodon/Activity/SafariActivity.swift
@@ -0,0 +1,62 @@
+//
+// SafariActivity.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-8.
+//
+
+import UIKit
+import SafariServices
+
+final class SafariActivity: UIActivity {
+
+ weak var sceneCoordinator: SceneCoordinator?
+ var url: NSURL?
+
+ init(sceneCoordinator: SceneCoordinator) {
+ self.sceneCoordinator = sceneCoordinator
+ }
+
+ override var activityType: UIActivity.ActivityType? {
+ return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity")
+ }
+
+ override var activityTitle: String? {
+ return L10n.Common.Controls.Actions.openInSafari
+ }
+
+ override var activityImage: UIImage? {
+ return UIImage(systemName: "safari")
+ }
+
+ override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
+ for item in activityItems {
+ guard let _ = item as? NSURL, sceneCoordinator != nil else { continue }
+ return true
+ }
+
+ return false
+ }
+
+ override func prepare(withActivityItems activityItems: [Any]) {
+ for item in activityItems {
+ guard let url = item as? NSURL else { continue }
+ self.url = url
+ }
+ }
+
+ override var activityViewController: UIViewController? {
+ return nil
+ }
+
+ override func perform() {
+ guard let url = url else {
+ activityDidFinish(false)
+ return
+ }
+
+ sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil))
+ activityDidFinish(true)
+ }
+
+}
diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift
index c76c8ba47..36d42745e 100644
--- a/Mastodon/Coordinator/SceneCoordinator.swift
+++ b/Mastodon/Coordinator/SceneCoordinator.swift
@@ -33,8 +33,8 @@ extension SceneCoordinator {
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
case customPush
case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
- case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
case alertController(animated: Bool, completion: (() -> Void)? = nil)
+ case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
}
enum Scene {
@@ -56,10 +56,12 @@ extension SceneCoordinator {
// profile
case profile(viewModel: ProfileViewModel)
+ case favorite(viewModel: FavoriteViewModel)
// misc
- case alertController(alertController: UIAlertController)
case safari(url: URL)
+ case alertController(alertController: UIAlertController)
+ case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
#if DEBUG
case publicTimeline
@@ -169,11 +171,11 @@ extension SceneCoordinator {
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
- case .activityViewControllerPresent(let animated, let completion):
+ case .alertController(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
- case .alertController(let animated, let completion):
+ case .activityViewControllerPresent(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
}
@@ -232,6 +234,16 @@ private extension SceneCoordinator {
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
viewController = _viewController
+ case .favorite(let viewModel):
+ let _viewController = FavoriteViewController()
+ _viewController.viewModel = viewModel
+ viewController = _viewController
+ case .safari(let url):
+ guard let scheme = url.scheme?.lowercased(),
+ scheme == "http" || scheme == "https" else {
+ return nil
+ }
+ viewController = SFSafariViewController(url: url)
case .alertController(let alertController):
if let popoverPresentationController = alertController.popoverPresentationController {
assert(
@@ -241,12 +253,10 @@ private extension SceneCoordinator {
)
}
viewController = alertController
- case .safari(let url):
- guard let scheme = url.scheme?.lowercased(),
- scheme == "http" || scheme == "https" else {
- return nil
- }
- viewController = SFSafariViewController(url: url)
+ case .activityViewController(let activityViewController, let sourceView, let barButtonItem):
+ activityViewController.popoverPresentationController?.sourceView = sourceView
+ activityViewController.popoverPresentationController?.barButtonItem = barButtonItem
+ viewController = activityViewController
#if DEBUG
case .publicTimeline:
let _viewController = PublicTimelineViewController()
diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift
index 0a27f1871..9f82f6ca5 100644
--- a/Mastodon/Diffiable/Item/Item.swift
+++ b/Mastodon/Diffiable/Item/Item.swift
@@ -63,11 +63,21 @@ extension Item {
let id = UUID()
let reason: Reason
- enum Reason {
+ enum Reason: Equatable {
case noStatusFound
case blocking
case blocked
- case suspended
+ case suspended(name: String?)
+
+ static func == (lhs: Item.EmptyStateHeaderAttribute.Reason, rhs: Item.EmptyStateHeaderAttribute.Reason) -> Bool {
+ switch (lhs, rhs) {
+ case (.noStatusFound, noStatusFound): return true
+ case (.blocking, blocking): return true
+ case (.blocked, blocked): return true
+ case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight
+ default: return false
+ }
+ }
}
init(reason: Reason) {
diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift
index 52443a13d..456d193f3 100644
--- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift
+++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift
@@ -16,7 +16,8 @@ extension CategoryPickerSection {
for collectionView: UICollectionView,
dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource {
- UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
+ UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
+ guard let _ = dependency else { return nil }
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
switch item {
case .all:
diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift
index 2167d6f5c..06b626d0e 100644
--- a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift
+++ b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift
@@ -17,7 +17,8 @@ extension CustomEmojiPickerSection {
for collectionView: UICollectionView,
dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource {
- let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
+ let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
+ guard let _ = dependency else { return nil }
switch item {
case .emoji(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell
diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift
index 083e9b813..f5b1ee500 100644
--- a/Mastodon/Diffiable/Section/PickServerSection.swift
+++ b/Mastodon/Diffiable/Section/PickServerSection.swift
@@ -25,7 +25,13 @@ extension PickServerSection {
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
pickServerCellDelegate: PickServerCellDelegate
) -> UITableViewDiffableDataSource {
- UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerCategoriesCellDelegate, weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in
+ UITableViewDiffableDataSource(tableView: tableView) { [
+ weak dependency,
+ weak pickServerCategoriesCellDelegate,
+ weak pickServerSearchCellDelegate,
+ weak pickServerCellDelegate
+ ] tableView, indexPath, item -> UITableViewCell? in
+ guard let dependency = dependency else { return nil }
switch item {
case .header:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell
diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift
index e140ab95a..bb99b15d4 100644
--- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift
+++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift
@@ -28,6 +28,7 @@ extension MastodonUser.Property {
followersCount: entity.followersCount,
locked: entity.locked,
bot: entity.bot,
+ suspended: entity.suspended,
createdAt: entity.createdAt,
networkDate: networkDate
)
@@ -62,3 +63,21 @@ extension MastodonUser {
}
}
+
+extension MastodonUser {
+
+ var profileURL: URL {
+ if let urlString = self.url,
+ let url = URL(string: urlString) {
+ return url
+ } else {
+ return URL(string: "https://\(self.domain)/@\(username)")!
+ }
+ }
+
+ var activityItems: [Any] {
+ var items: [Any] = []
+ items.append(profileURL)
+ return items
+ }
+}
diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift
index 6ee852bd9..7e2177348 100644
--- a/Mastodon/Generated/Assets.swift
+++ b/Mastodon/Generated/Assets.swift
@@ -86,6 +86,8 @@ internal enum Asset {
}
internal enum Profile {
internal enum Banner {
+ internal static let bioEditBackgroundGray = ColorAsset(name: "Profile/Banner/bio.edit.background.gray")
+ internal static let nameEditBackgroundGray = ColorAsset(name: "Profile/Banner/name.edit.background.gray")
internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray")
}
}
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 83fb0adea..da27f7a67 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -78,6 +78,12 @@ internal enum L10n {
internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto")
/// See More
internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore")
+ /// Share
+ internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share")
+ /// Share %@
+ internal static func shareUser(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Common.Controls.Actions.ShareUser", String(describing: p1))
+ }
/// Sign In
internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn")
/// Sign Up
@@ -90,6 +96,10 @@ internal enum L10n {
internal enum Firendship {
/// Block
internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block")
+ /// Block %@
+ internal static func blockDomain(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain", String(describing: p1))
+ }
/// Blocked
internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked")
/// Block %@
@@ -112,6 +122,8 @@ internal enum L10n {
}
/// Pending
internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending")
+ /// Request
+ internal static let request = L10n.tr("Localizable", "Common.Controls.Firendship.Request")
/// Unblock
internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock")
/// Unblock %@
@@ -179,8 +191,12 @@ internal enum L10n {
internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning")
/// No Status Found
internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound")
- /// This account is suspended.
+ /// This account has been suspended.
internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning")
+ /// %@'s account has been suspended.
+ internal static func userSuspendedWarning(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1))
+ }
}
internal enum Loader {
/// Loading missing posts...
@@ -299,6 +315,10 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title")
}
}
+ internal enum Favorite {
+ /// Your Favorites
+ internal static let title = L10n.tr("Localizable", "Scene.Favorite.Title")
+ }
internal enum Hashtag {
/// %@ people talking
internal static func prompt(_ p1: Any) -> String {
@@ -320,6 +340,10 @@ internal enum L10n {
}
}
internal enum Profile {
+ /// %@ posts
+ internal static func subtitle(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Scene.Profile.Subtitle", String(describing: p1))
+ }
internal enum Dashboard {
/// followers
internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers")
diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift
index b8c5285a2..1c2c78da3 100644
--- a/Mastodon/Protocol/AvatarConfigurableView.swift
+++ b/Mastodon/Protocol/AvatarConfigurableView.swift
@@ -26,7 +26,7 @@ extension AvatarConfigurableView {
if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 {
return placeholderImage
.af.imageAspectScaled(toFill: Self.configurableAvatarImageSize)
- .af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true)
+ .af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: true)
} else {
return placeholderImage.af.imageRoundedIntoCircle()
}
@@ -50,11 +50,20 @@ extension AvatarConfigurableView {
defer {
avatarConfigurableView(self, didFinishConfiguration: configuration)
}
-
+
+ let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
+
// set placeholder if no asset
guard let avatarImageURL = configuration.avatarImageURL else {
configurableAvatarImageView?.image = placeholderImage
+ configurableAvatarImageView?.layer.masksToBounds = true
+ configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
+ configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
+
configurableAvatarButton?.setImage(placeholderImage, for: .normal)
+ configurableAvatarButton?.layer.masksToBounds = true
+ configurableAvatarButton?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
+ configurableAvatarButton?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
return
}
@@ -74,7 +83,6 @@ extension AvatarConfigurableView {
avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
default:
- let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarImageView.af.setImage(
withURL: avatarImageURL,
placeholderImage: placeholderImage,
@@ -103,7 +111,6 @@ extension AvatarConfigurableView {
avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular
default:
- let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarButton.af.setImage(
for: .normal,
url: avatarImageURL,
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
index baaa708a1..32915baf9 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
@@ -95,7 +95,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
videoPlayerViewModel.didEndDisplaying()
}
}
- if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = status?.mediaAttachments?.contains(currentAudioAttachment) {
+ if let currentAudioAttachment = self.context.audioPlaybackService.attachment,
+ status?.mediaAttachments?.contains(currentAudioAttachment) == true {
self.context.audioPlaybackService.pause()
}
}
diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
index d1c24c97f..abdc27902 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
@@ -173,7 +173,7 @@ extension StatusProviderFacade {
return (status.objectID, favoriteKind)
}
.map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in
- return context.apiService.like(
+ return context.apiService.favorite(
statusObjectID: statusObjectID,
mastodonUserObjectID: mastodonUserObjectID,
favoriteKind: favoriteKind
@@ -201,7 +201,7 @@ extension StatusProviderFacade {
}
}
.map { statusID, favoriteKind in
- return context.apiService.like(
+ return context.apiService.favorite(
statusID: statusID,
favoriteKind: favoriteKind,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift
new file mode 100644
index 000000000..ecd8291ff
--- /dev/null
+++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift
@@ -0,0 +1,123 @@
+//
+// StatusTableViewControllerAspect.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-7.
+//
+
+import UIKit
+import AVKit
+
+// Check List Last Updated
+// - FavoriteViewController: 2021/4/8
+// - HashtagTimelineViewController: 2021/4/8
+// - UserTimelineViewController: 2021/4/8
+// * StatusTableViewControllerAspect: 2021/4/7
+
+// (Fake) Aspect protocol to group common protocol extension implementations
+// Needs update related view controller when aspect interface changes
+
+/// Status related operations aspect
+/// Please check the aspect methods (Option+Click) and add hook to implement features
+/// - UI
+/// - Media
+/// - Data Source
+protocol StatusTableViewControllerAspect: UIViewController {
+ var tableView: UITableView { get }
+}
+
+// MARK: - UIViewController [A]
+
+// [A1] aspectViewWillAppear(_:)
+extension StatusTableViewControllerAspect {
+ /// [UI] hook to deselect row in the transitioning for the table view
+ func aspectViewWillAppear(_ animated: Bool) {
+ tableView.deselectRow(with: transitionCoordinator, animated: animated)
+ }
+}
+
+extension StatusTableViewControllerAspect where Self: NeedsDependency {
+ /// [Media] hook to notify video service
+ func aspectViewDidDisappear(_ animated: Bool) {
+ context.videoPlaybackService.viewDidDisappear(from: self)
+ context.audioPlaybackService.viewDidDisappear(from: self)
+ }
+}
+
+// MARK: - UITableViewDelegate [B]
+
+// [B1] aspectTableView(_:estimatedHeightForRowAt:)
+extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer {
+ /// [Data Source] hook to notify table view bottom loader
+ func aspectScrollViewDidScroll(_ scrollView: UIScrollView) {
+ handleScrollViewDidScroll(scrollView)
+ }
+}
+
+// [B2] aspectTableView(_:estimatedHeightForRowAt:)
+extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer {
+ /// [UI] hook to estimate table view cell height from cache
+ func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
+ handleTableView(tableView, estimatedHeightForRowAt: indexPath)
+ }
+}
+
+// [B3] aspectTableView(_:willDisplay:forRowAt:)
+extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
+ func aspectTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
+ }
+}
+
+// [B4] StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:)
+extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
+ /// [Media] hook to notify video service
+ func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ }
+}
+
+extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer & StatusProvider {
+ /// [UI] hook to cache table view cell height
+ func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ }
+}
+
+extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer & StatusProvider {
+ /// [Media] hook to notify video service
+ /// [UI] hook to cache table view cell height
+ func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ }
+}
+
+// MARK: - UITableViewDataSourcePrefetching [C]
+
+// [C1] aspectTableView(:prefetchRowsAt)
+extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider {
+ /// [Data Source] hook to prefetch reply to info for status
+ func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
+ handleTableView(tableView, prefetchRowsAt: indexPaths)
+ }
+}
+
+// MARK: - AVPlayerViewControllerDelegate & NeedsDependency [D]
+
+// [D1] aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:)
+extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency {
+ /// [Media] hook to mark transitioning to video service
+ func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
+ handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
+ }
+}
+
+// [D2] aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:)
+extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency {
+ /// [Media] hook to mark transitioning to video service
+ func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
+ handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
+ }
+}
+
diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift
index 1b0350086..0907db56f 100644
--- a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift
+++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift
@@ -7,6 +7,30 @@
import UIKit
-protocol TableViewCellHeightCacheableContainer: UIViewController {
- // TODO:
+protocol TableViewCellHeightCacheableContainer: StatusProvider {
+ var cellFrameCache: NSCache { get }
+}
+
+extension TableViewCellHeightCacheableContainer {
+
+ func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ guard let item = item(for: nil, indexPath: indexPath) else { return }
+
+ let key = item.hashValue
+ let frame = cell.frame
+ cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
+ }
+
+ func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
+ guard let item = item(for: nil, indexPath: indexPath) else { return UITableView.automaticDimension }
+ guard let frame = cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
+ if case .bottomLoader = item {
+ return TimelineLoaderTableViewCell.cellHeight
+ } else {
+ return UITableView.automaticDimension
+ }
+ }
+
+ return ceil(frame.height)
+ }
}
diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift
index 04297772b..b5f4dd32f 100644
--- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift
+++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift
@@ -139,7 +139,10 @@ extension UserProviderFacade {
for mastodonUser: MastodonUser,
isMuting: Bool,
isBlocking: Bool,
- provider: UserProvider
+ needsShareAction: Bool,
+ provider: UserProvider,
+ sourceView: UIView?,
+ barButtonItem: UIBarButtonItem?
) -> UIMenu {
var children: [UIMenuElement] = []
let name = mastodonUser.displayNameWithFallback
@@ -198,7 +201,32 @@ extension UserProviderFacade {
children.append(blockMenu)
}
+ if needsShareAction {
+ let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in
+ guard let provider = provider else { return }
+ let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: provider)
+ provider.coordinator.present(
+ scene: .activityViewController(
+ activityViewController: activityViewController,
+ sourceView: sourceView,
+ barButtonItem: barButtonItem
+ ),
+ from: provider,
+ transition: .activityViewControllerPresent(animated: true, completion: nil)
+ )
+ }
+ children.append(shareAction)
+ }
+
return UIMenu(title: "", options: [], children: children)
}
+ static func createActivityViewControllerForMastodonUser(mastodonUser: MastodonUser, dependency: NeedsDependency) -> UIActivityViewController {
+ let activityViewController = UIActivityViewController(
+ activityItems: mastodonUser.activityItems,
+ applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)]
+ )
+ return activityViewController
+ }
+
}
diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json
new file mode 100644
index 000000000..aa5323a21
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.200",
+ "blue" : "128",
+ "green" : "120",
+ "red" : "120"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.360",
+ "blue" : "128",
+ "green" : "120",
+ "red" : "120"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json
new file mode 100644
index 000000000..b4ce9fd5b
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.360",
+ "blue" : "128",
+ "green" : "120",
+ "red" : "120"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index 90d0b11a7..e94101cc4 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -24,11 +24,14 @@ Please check your internet connection.";
"Common.Controls.Actions.Save" = "Save";
"Common.Controls.Actions.SavePhoto" = "Save photo";
"Common.Controls.Actions.SeeMore" = "See More";
+"Common.Controls.Actions.Share" = "Share";
+"Common.Controls.Actions.ShareUser" = "Share %@";
"Common.Controls.Actions.SignIn" = "Sign In";
"Common.Controls.Actions.SignUp" = "Sign Up";
"Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Actions.TryAgain" = "Try Again";
"Common.Controls.Firendship.Block" = "Block";
+"Common.Controls.Firendship.BlockDomain" = "Block %@";
"Common.Controls.Firendship.BlockUser" = "Block %@";
"Common.Controls.Firendship.Blocked" = "Blocked";
"Common.Controls.Firendship.EditInfo" = "Edit info";
@@ -38,6 +41,7 @@ Please check your internet connection.";
"Common.Controls.Firendship.MuteUser" = "Mute %@";
"Common.Controls.Firendship.Muted" = "Muted";
"Common.Controls.Firendship.Pending" = "Pending";
+"Common.Controls.Firendship.Request" = "Request";
"Common.Controls.Firendship.Unblock" = "Unblock";
"Common.Controls.Firendship.UnblockUser" = "Unblock %@";
"Common.Controls.Firendship.Unmute" = "Unmute";
@@ -60,7 +64,8 @@ Please check your internet connection.";
until you unblock them.
Your account looks like this to them.";
"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
-"Common.Controls.Timeline.Header.SuspendedWarning" = "This account is suspended.";
+"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended.";
+"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended.";
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Countable.Photo.Multiple" = "photos";
@@ -102,6 +107,7 @@ uploaded to Mastodon.";
"Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@,
tap the link to confirm your account.";
"Scene.ConfirmEmail.Title" = "One last thing.";
+"Scene.Favorite.Title" = "Your Favorites";
"Scene.Hashtag.Prompt" = "%@ people talking";
"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts";
"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline";
@@ -118,6 +124,7 @@ tap the link to confirm your account.";
"Scene.Profile.SegmentedControl.Media" = "Media";
"Scene.Profile.SegmentedControl.Posts" = "Posts";
"Scene.Profile.SegmentedControl.Replies" = "Replies";
+"Scene.Profile.Subtitle" = "%@ posts";
"Scene.PublicTimeline.Title" = "Public";
"Scene.Register.Error.Item.Agreement" = "Agreement";
"Scene.Register.Error.Item.Email" = "Email";
diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift
index cefd7b238..c9bf87410 100644
--- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift
@@ -41,7 +41,7 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency {
let refreshControl = UIRefreshControl()
- let titleView = HashtagTimelineNavigationBarTitleView()
+ let titleView = DoubleTitleLabelNavigationBarTitleView()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
@@ -54,7 +54,7 @@ extension HashtagTimelineViewController {
super.viewDidLoad()
title = "#\(viewModel.hashtag)"
- titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil)
+ titleView.update(title: viewModel.hashtag, subtitle: nil)
navigationItem.titleView = titleView
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
@@ -107,13 +107,13 @@ extension HashtagTimelineViewController {
self?.updatePromptTitle()
}
.store(in: &disposeBag)
-
-
-
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
+
+ aspectViewWillAppear(animated)
+
viewModel.fetchTag()
guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return }
@@ -123,8 +123,8 @@ extension HashtagTimelineViewController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
- context.videoPlaybackService.viewDidDisappear(from: self)
- context.audioPlaybackService.viewDidDisappear(from: self)
+
+ aspectViewDidDisappear(animated)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
@@ -142,7 +142,7 @@ extension HashtagTimelineViewController {
private func updatePromptTitle() {
var subtitle: String?
defer {
- titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle)
+ titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle)
}
guard let histories = viewModel.hashtagEntity.value?.history else {
return
@@ -158,9 +158,10 @@ extension HashtagTimelineViewController {
.prefix(2)
.compactMap({ Int($0.accounts) })
.reduce(0, +)
- subtitle = "\(peopleTalkingNumber)"
+ subtitle = L10n.Scene.Hashtag.prompt("\(peopleTalkingNumber)")
}
}
+
}
extension HashtagTimelineViewController {
@@ -179,11 +180,20 @@ extension HashtagTimelineViewController {
}
}
+// MARK: - StatusTableViewControllerAspect
+extension HashtagTimelineViewController: StatusTableViewControllerAspect { }
+
+// MARK: - TableViewCellHeightCacheableContainer
+extension HashtagTimelineViewController: TableViewCellHeightCacheableContainer {
+ var cellFrameCache: NSCache {
+ return viewModel.cellFrameCache
+ }
+}
+
// MARK: - UIScrollViewDelegate
extension HashtagTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
- handleScrollViewDidScroll(scrollView)
-// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView)
+ aspectScrollViewDidScroll(scrollView)
}
}
@@ -197,25 +207,16 @@ extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer
// MARK: - UITableViewDelegate
extension HashtagTimelineViewController: UITableViewDelegate {
- // TODO:
- // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
- // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
- // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
- //
- // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
- // return 200
- // }
- // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
- //
- // return ceil(frame.height)
- // }
+ func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
+ return aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
+ }
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
- handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
+ aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
- handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
}
@@ -230,7 +231,7 @@ extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewCont
// MARK: - UITableViewDataSourcePrefetching
extension HashtagTimelineViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
- handleTableView(tableView, prefetchRowsAt: indexPaths)
+ aspectTableView(tableView, prefetchRowsAt: indexPaths)
}
}
@@ -301,11 +302,11 @@ extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelega
extension HashtagTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
- handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
+ aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
- handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
+ aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift
index d0607550e..e5c78f3d5 100644
--- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift
@@ -35,6 +35,8 @@ extension HashtagTimelineViewModel.LoadOldestState {
}
class Loading: HashtagTimelineViewModel.LoadOldestState {
+ var maxID: String?
+
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
}
@@ -54,7 +56,7 @@ extension HashtagTimelineViewModel.LoadOldestState {
}
// TODO: only set large count when using Wi-Fi
- let maxID = last.id
+ let maxID = self.maxID ?? last.id
viewModel.context.apiService.hashtagTimeline(
domain: activeMastodonAuthenticationBox.domain,
maxID: maxID,
@@ -71,10 +73,19 @@ extension HashtagTimelineViewModel.LoadOldestState {
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
- } receiveValue: { response in
+ } receiveValue: { [weak self] response in
+ guard let self = self else { return }
+
let statuses = response.value
// enter no more state when no new statuses
- if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) {
+
+ let hasNextPage: Bool = {
+ guard let link = response.link else { return true } // assert has more when link invalid
+ return link.maxID != nil
+ }()
+ self.maxID = response.link?.maxID
+
+ if !hasNextPage || statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) {
stateMachine.enter(NoMore.self)
} else {
stateMachine.enter(Idle.self)
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
index 785d97264..0c43af79e 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
@@ -21,10 +21,18 @@ extension HomeTimelineViewController {
children: [
moveMenu,
dropMenu,
+ UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
+ guard let self = self else { return }
+ self.showWelcomeAction(action)
+ },
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showPublicTimelineAction(action)
},
+ UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in
+ guard let self = self else { return }
+ self.showProfileAction(action)
+ },
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return }
self.signOutAction(action)
@@ -273,9 +281,28 @@ extension HomeTimelineViewController {
.store(in: &disposeBag)
}
+ @objc private func showWelcomeAction(_ sender: UIAction) {
+ coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
+ }
+
@objc private func showPublicTimelineAction(_ sender: UIAction) {
coordinator.present(scene: .publicTimeline, from: self, transition: .show)
}
+ @objc private func showProfileAction(_ sender: UIAction) {
+ let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert)
+ alertController.addTextField()
+ let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
+ guard let self = self else { return }
+ guard let textField = alertController?.textFields?.first else { return }
+ let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "")
+ self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
+ }
+ alertController.addAction(showAction)
+ let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
+ alertController.addAction(cancelAction)
+ coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
+ }
+
}
#endif
diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift
index 721aae9bc..241a597c1 100644
--- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift
+++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift
@@ -66,6 +66,17 @@ extension MastodonPickServerViewController {
setupOnboardingAppearance()
defer { setupNavigationBarBackgroundView() }
+
+ #if DEBUG
+ navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil)
+ let children: [UIMenuElement] = [
+ UIAction(title: "Dismiss", image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
+ guard let self = self else { return }
+ self.dismiss(animated: true, completion: nil)
+ })
+ ]
+ navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children)
+ #endif
view.addSubview(nextStepButton)
NSLayoutConstraint.activate([
diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift
index 09b6c327c..ed804afd9 100644
--- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift
+++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift
@@ -75,6 +75,14 @@ class MastodonPickServerViewModel: NSObject {
configure()
}
+ deinit {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+
+}
+
+extension MastodonPickServerViewModel {
+
private func configure() {
Publishers.CombineLatest(
filteredIndexedServers.eraseToAnyPublisher(),
diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift
index 5c5f77600..b6ba6a8af 100644
--- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift
+++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift
@@ -42,7 +42,7 @@ extension MastodonRegisterViewController {
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
- private func cropImage(image:UIImage,pickerViewController:UIViewController) {
+ private func cropImage(image: UIImage, pickerViewController: UIViewController) {
DispatchQueue.main.async {
let cropController = CropViewController(croppingStyle: .default, image: image)
cropController.delegate = self
diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift
index 6439ea42b..2187ad52b 100644
--- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift
+++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift
@@ -13,6 +13,9 @@ import PhotosUI
import UIKit
final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance {
+
+ static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400)
+
var disposeBag = Set()
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
@@ -684,10 +687,10 @@ extension MastodonRegisterViewController {
let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value
let avatar: Mastodon.Query.MediaAttachment? = {
guard let avatarImage = self.viewModel.avatarImage.value else { return nil }
- guard avatarImage.size.width <= 400 else {
- return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8))
+ guard avatarImage.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else {
+ return .png(avatarImage.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel).pngData())
}
- return .jpeg(avatarImage.jpegData(compressionQuality: 0.8))
+ return .png(avatarImage.pngData())
}()
return Mastodon.API.Account.UpdateCredentialQuery(
displayName: displayName,
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift
new file mode 100644
index 000000000..2dadc8545
--- /dev/null
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift
@@ -0,0 +1,87 @@
+//
+// FavoriteViewController+StatusProvider.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-7.
+//
+
+import os.log
+import UIKit
+import Combine
+import CoreData
+import CoreDataStack
+
+// MARK: - StatusProvider
+extension FavoriteViewController: StatusProvider {
+
+ func status() -> Future {
+ return Future { promise in promise(.success(nil)) }
+ }
+
+ func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future {
+ return Future { promise in
+ guard let diffableDataSource = self.viewModel.diffableDataSource else {
+ assertionFailure()
+ promise(.success(nil))
+ return
+ }
+ guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
+ let item = diffableDataSource.itemIdentifier(for: indexPath) else {
+ promise(.success(nil))
+ return
+ }
+
+ switch item {
+ case .status(let objectID, _):
+ let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
+ managedObjectContext.perform {
+ let status = managedObjectContext.object(with: objectID) as? Status
+ promise(.success(status))
+ }
+ default:
+ promise(.success(nil))
+ }
+ }
+ }
+
+ func status(for cell: UICollectionViewCell) -> Future {
+ return Future { promise in promise(.success(nil)) }
+ }
+
+ var managedObjectContext: NSManagedObjectContext {
+ return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
+ }
+
+ var tableViewDiffableDataSource: UITableViewDiffableDataSource? {
+ return viewModel.diffableDataSource
+ }
+
+ func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
+ guard let diffableDataSource = self.viewModel.diffableDataSource else {
+ assertionFailure()
+ return nil
+ }
+
+ guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
+ let item = diffableDataSource.itemIdentifier(for: indexPath) else {
+ return nil
+ }
+
+ return item
+ }
+
+ func items(indexPaths: [IndexPath]) -> [Item] {
+ guard let diffableDataSource = self.viewModel.diffableDataSource else {
+ assertionFailure()
+ return []
+ }
+
+ var items: [Item] = []
+ for indexPath in indexPaths {
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
+ items.append(item)
+ }
+ return items
+ }
+
+}
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift
new file mode 100644
index 000000000..a175ae348
--- /dev/null
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift
@@ -0,0 +1,153 @@
+//
+// FavoriteViewController.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-6.
+//
+
+// Note: Prefer use US favorite then EN favourite in coding
+// to following the text checker auto-correct behavior
+
+import os.log
+import UIKit
+import AVKit
+import Combine
+import GameplayKit
+
+final class FavoriteViewController: UIViewController, NeedsDependency {
+
+ weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
+ weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
+
+ var disposeBag = Set()
+ var viewModel: FavoriteViewModel!
+
+ let titleView = DoubleTitleLabelNavigationBarTitleView()
+
+ lazy var tableView: UITableView = {
+ let tableView = UITableView()
+ tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
+ tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.separatorStyle = .none
+ tableView.backgroundColor = .clear
+ return tableView
+ }()
+
+ deinit {
+ os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+
+}
+
+extension FavoriteViewController {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ navigationItem.titleView = titleView
+ titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil)
+
+ tableView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(tableView)
+ NSLayoutConstraint.activate([
+ tableView.topAnchor.constraint(equalTo: view.topAnchor),
+ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
+ tableView.delegate = self
+ tableView.prefetchDataSource = self
+ viewModel.setupDiffableDataSource(
+ for: tableView,
+ dependency: self,
+ statusTableViewCellDelegate: self
+ )
+
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+
+ aspectViewWillAppear(animated)
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+
+ aspectViewDidDisappear(animated)
+ }
+
+}
+
+// MARK: - StatusTableViewControllerAspect
+extension FavoriteViewController: StatusTableViewControllerAspect { }
+
+// MARK: - TableViewCellHeightCacheableContainer
+extension FavoriteViewController: TableViewCellHeightCacheableContainer {
+ var cellFrameCache: NSCache {
+ return viewModel.cellFrameCache
+ }
+}
+
+// MARK: - UIScrollViewDelegate
+extension FavoriteViewController {
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ aspectScrollViewDidScroll(scrollView)
+ }
+}
+
+// MARK: - UITableViewDelegate
+extension FavoriteViewController: UITableViewDelegate {
+
+ func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
+ aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ }
+
+}
+
+// MARK: - UITableViewDataSourcePrefetching
+extension FavoriteViewController: UITableViewDataSourcePrefetching {
+ func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
+ aspectTableView(tableView, prefetchRowsAt: indexPaths)
+ }
+}
+
+// MARK: - AVPlayerViewControllerDelegate
+extension FavoriteViewController: AVPlayerViewControllerDelegate {
+
+ func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
+ aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
+ }
+
+ func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
+ aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
+ }
+
+}
+
+// MARK: - TimelinePostTableViewCellDelegate
+extension FavoriteViewController: StatusTableViewCellDelegate {
+ weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
+ func parent() -> UIViewController { return self }
+}
+
+// MARK: - LoadMoreConfigurableTableViewContainer
+extension FavoriteViewController: LoadMoreConfigurableTableViewContainer {
+ typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
+ typealias LoadingState = FavoriteViewModel.State.Loading
+
+ var loadMoreConfigurableTableView: UITableView { return tableView }
+ var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
+}
+
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift
new file mode 100644
index 000000000..e64df2c99
--- /dev/null
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift
@@ -0,0 +1,39 @@
+//
+// FavoriteViewModel+Diffable.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-7.
+//
+
+import UIKit
+
+extension FavoriteViewModel {
+
+ func setupDiffableDataSource(
+ for tableView: UITableView,
+ dependency: NeedsDependency,
+ statusTableViewCellDelegate: StatusTableViewCellDelegate
+ ) {
+ let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
+ .autoconnect()
+ .share()
+ .eraseToAnyPublisher()
+
+ diffableDataSource = StatusSection.tableViewDiffableDataSource(
+ for: tableView,
+ dependency: dependency,
+ managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
+ timestampUpdatePublisher: timestampUpdatePublisher,
+ statusTableViewCellDelegate: statusTableViewCellDelegate,
+ timelineMiddleLoaderTableViewCellDelegate: nil
+ )
+
+ // set empty section to make update animation top-to-bottom style
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([.main])
+ diffableDataSource?.apply(snapshot)
+
+ stateMachine.enter(State.Reloading.self)
+ }
+
+}
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift
new file mode 100644
index 000000000..c4420e88b
--- /dev/null
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift
@@ -0,0 +1,177 @@
+//
+// FavoriteViewModel+State.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-7.
+//
+
+import os.log
+import Foundation
+import GameplayKit
+import MastodonSDK
+
+extension FavoriteViewModel {
+ class State: GKState {
+ weak var viewModel: FavoriteViewModel?
+
+ init(viewModel: FavoriteViewModel) {
+ self.viewModel = viewModel
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
+ }
+ }
+}
+
+extension FavoriteViewModel.State {
+ class Initial: FavoriteViewModel.State {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ guard let viewModel = viewModel else { return false }
+ switch stateClass {
+ case is Reloading.Type:
+ return viewModel.activeMastodonAuthenticationBox.value != nil
+ default:
+ return false
+ }
+ }
+ }
+
+ class Reloading: FavoriteViewModel.State {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ switch stateClass {
+ case is Loading.Type:
+ return true
+ default:
+ return false
+ }
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+ guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
+
+ // reset
+ viewModel.statusFetchedResultsController.statusIDs.value = []
+
+ stateMachine.enter(Loading.self)
+ }
+ }
+
+ class Fail: FavoriteViewModel.State {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ switch stateClass {
+ case is Loading.Type:
+ return true
+ default:
+ return false
+ }
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+ guard let _ = viewModel, let stateMachine = stateMachine else { return }
+
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
+ stateMachine.enter(Loading.self)
+ }
+ }
+ }
+
+ class Idle: FavoriteViewModel.State {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ switch stateClass {
+ case is Reloading.Type, is Loading.Type:
+ return true
+ default:
+ return false
+ }
+ }
+ }
+
+ class Loading: FavoriteViewModel.State {
+
+ var maxID: String?
+
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ switch stateClass {
+ case is Fail.Type:
+ return true
+ case is Idle.Type:
+ return true
+ case is NoMore.Type:
+ return true
+ default:
+ return false
+ }
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+ guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
+
+ guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
+ stateMachine.enter(Fail.self)
+ return
+ }
+ if previousState is Reloading {
+ maxID = nil
+ }
+ // prefer use `maxID` token in response header
+ // let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last
+
+ viewModel.context.apiService.favoritedStatuses(
+ maxID: maxID,
+ mastodonAuthenticationBox: activeMastodonAuthenticationBox
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { completion in
+ switch completion {
+ case .failure(let error):
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
+ stateMachine.enter(Fail.self)
+ case .finished:
+ break
+ }
+ } receiveValue: { response in
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+
+ var hasNewStatusesAppend = false
+ var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
+ for status in response.value {
+ guard !statusIDs.contains(status.id) else { continue }
+ statusIDs.append(status.id)
+ hasNewStatusesAppend = true
+ }
+
+ self.maxID = response.link?.maxID
+
+ let hasNextPage: Bool = {
+ guard let link = response.link else { return true } // assert has more when link invalid
+ return link.maxID != nil
+ }()
+
+ if hasNewStatusesAppend && hasNextPage {
+ stateMachine.enter(Idle.self)
+ } else {
+ stateMachine.enter(NoMore.self)
+ }
+ viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
+ }
+ .store(in: &viewModel.disposeBag)
+ }
+ }
+
+ class NoMore: FavoriteViewModel.State {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ switch stateClass {
+ case is Reloading.Type:
+ return true
+ default:
+ return false
+ }
+ }
+ }
+}
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift
new file mode 100644
index 000000000..589ffe190
--- /dev/null
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift
@@ -0,0 +1,101 @@
+//
+// FavoriteViewModel.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-6.
+//
+
+import UIKit
+import Combine
+import CoreData
+import CoreDataStack
+import GameplayKit
+
+final class FavoriteViewModel {
+
+ var disposeBag = Set()
+
+ // input
+ let context: AppContext
+ let activeMastodonAuthenticationBox: CurrentValueSubject
+ let statusFetchedResultsController: StatusFetchedResultsController
+ let cellFrameCache = NSCache()
+
+ // output
+ var diffableDataSource: UITableViewDiffableDataSource?
+ private(set) lazy var stateMachine: GKStateMachine = {
+ let stateMachine = GKStateMachine(states: [
+ State.Initial(viewModel: self),
+ State.Reloading(viewModel: self),
+ State.Fail(viewModel: self),
+ State.Idle(viewModel: self),
+ State.Loading(viewModel: self),
+ State.NoMore(viewModel: self),
+ ])
+ stateMachine.enter(State.Initial.self)
+ return stateMachine
+ }()
+
+
+ init(context: AppContext) {
+ self.context = context
+ self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
+ self.statusFetchedResultsController = StatusFetchedResultsController(
+ managedObjectContext: context.managedObjectContext,
+ domain: nil,
+ additionalTweetPredicate: Status.notDeleted()
+ )
+
+ context.authenticationService.activeMastodonAuthenticationBox
+ .assign(to: \.value, on: activeMastodonAuthenticationBox)
+ .store(in: &disposeBag)
+
+ activeMastodonAuthenticationBox
+ .map { $0?.domain }
+ .assign(to: \.value, on: statusFetchedResultsController.domain)
+ .store(in: &disposeBag)
+
+ statusFetchedResultsController.objectIDs
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] objectIDs in
+ guard let self = self else { return }
+ guard let diffableDataSource = self.diffableDataSource else { return }
+
+ var items: [Item] = []
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([.main])
+
+ defer {
+ // not animate when empty items fix loader first appear layout issue
+ diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
+ }
+
+ var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
+ let oldSnapshot = diffableDataSource.snapshot()
+ for item in oldSnapshot.itemIdentifiers {
+ guard case let .status(objectID, attribute) = item else { continue }
+ oldSnapshotAttributeDict[objectID] = attribute
+ }
+
+ for objectID in objectIDs {
+ let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
+ items.append(.status(objectID: objectID, attribute: attribute))
+ }
+ snapshot.appendItems(items, toSection: .main)
+
+ if let currentState = self.stateMachine.currentState {
+ switch currentState {
+ case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
+ snapshot.appendItems([.bottomLoader], toSection: .main)
+ case is State.NoMore:
+ break
+ // TODO: handle other states
+ default:
+ break
+ }
+ }
+ }
+ .store(in: &disposeBag)
+ }
+
+}
diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift
index 855581902..38695f34f 100644
--- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift
+++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift
@@ -7,6 +7,11 @@
import os.log
import UIKit
+import Combine
+import PhotosUI
+import AlamofireImage
+import CropViewController
+import TwitterTextEditor
protocol ProfileHeaderViewControllerDelegate: class {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
@@ -19,8 +24,21 @@ final class ProfileHeaderViewController: UIViewController {
static let segmentedControlMarginHeight: CGFloat = 20
static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight
+ var disposeBag = Set()
weak var delegate: ProfileHeaderViewControllerDelegate?
+ var viewModel: ProfileHeaderViewModel!
+
+ let titleView: DoubleTitleLabelNavigationBarTitleView = {
+ let titleView = DoubleTitleLabelNavigationBarTitleView()
+ titleView.titleLabel.textColor = .white
+ titleView.titleLabel.alpha = 0
+ titleView.subtitleLabel.textColor = .white
+ titleView.subtitleLabel.alpha = 0
+ titleView.layer.masksToBounds = true
+ return titleView
+ }()
+
let profileHeaderView = ProfileHeaderView()
let pageSegmentedControl: UISegmentedControl = {
let segmenetedControl = UISegmentedControl(items: ["A", "B"])
@@ -33,6 +51,28 @@ final class ProfileHeaderViewController: UIViewController {
// private var isAdjustBannerImageViewForSafeAreaInset = false
private var containerSafeAreaInset: UIEdgeInsets = .zero
+
+ private(set) lazy var imagePicker: PHPickerViewController = {
+ var configuration = PHPickerConfiguration()
+ configuration.filter = .images
+ configuration.selectionLimit = 1
+
+ let imagePicker = PHPickerViewController(configuration: configuration)
+ imagePicker.delegate = self
+ return imagePicker
+ }()
+ private(set) lazy var imagePickerController: UIImagePickerController = {
+ let imagePickerController = UIImagePickerController()
+ imagePickerController.sourceType = .camera
+ imagePickerController.delegate = self
+ return imagePickerController
+ }()
+
+ private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
+ let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
+ documentPickerController.delegate = self
+ return documentPickerController
+ }()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@@ -67,11 +107,99 @@ extension ProfileHeaderViewController {
])
pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged)
+
+ Publishers.CombineLatest(
+ viewModel.viewDidAppear.eraseToAnyPublisher(),
+ viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSetted in
+ guard let self = self else { return }
+ self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0
+ self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0
+ }
+ .store(in: &disposeBag)
+
+ viewModel.needsSetupBottomShadow
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] needsSetupBottomShadow in
+ guard let self = self else { return }
+ self.setupBottomShadow()
+ }
+ .store(in: &disposeBag)
+
+ Publishers.CombineLatest4(
+ viewModel.isEditing.eraseToAnyPublisher(),
+ viewModel.displayProfileInfo.avatarImageResource.eraseToAnyPublisher(),
+ viewModel.editProfileInfo.avatarImageResource.eraseToAnyPublisher(),
+ viewModel.viewDidAppear.eraseToAnyPublisher()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isEditing, resource, editingResource, _ in
+ guard let self = self else { return }
+ let url: URL? = {
+ guard case let .url(url) = resource else { return nil }
+ return url
+
+ }()
+ let image: UIImage? = {
+ guard case let .image(image) = editingResource else { return nil }
+ return image
+ }()
+ self.profileHeaderView.configure(
+ with: AvatarConfigurableViewConfiguration(
+ avatarImageURL: image == nil ? url : nil, // set only when image empty
+ placeholderImage: image,
+ borderColor: .white,
+ borderWidth: 2
+ )
+ )
+ }
+ .store(in: &disposeBag)
+ Publishers.CombineLatest3(
+ viewModel.isEditing.eraseToAnyPublisher(),
+ viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(),
+ viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isEditing, name, editingName in
+ guard let self = self else { return }
+ self.profileHeaderView.nameTextField.text = isEditing ? editingName : name
+ }
+ .store(in: &disposeBag)
+
+ Publishers.CombineLatest3(
+ viewModel.isEditing.eraseToAnyPublisher(),
+ viewModel.displayProfileInfo.note.removeDuplicates().eraseToAnyPublisher(),
+ viewModel.editProfileInfo.note.removeDuplicates().eraseToAnyPublisher()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isEditing, note, editingNote in
+ guard let self = self else { return }
+ self.profileHeaderView.bioActiveLabel.configure(note: note ?? "")
+ self.profileHeaderView.bioTextEditorView.text = editingNote ?? ""
+ }
+ .store(in: &disposeBag)
+
+ profileHeaderView.bioTextEditorView.changeObserver = self
+ NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField)
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] notification in
+ guard let self = self else { return }
+ guard let textField = notification.object as? UITextField else { return }
+ self.viewModel.editProfileInfo.name.value = textField.text
+ }
+ .store(in: &disposeBag)
+
+ profileHeaderView.editAvatarButton.menu = createAvatarContextMenu()
+ profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
+ viewModel.viewDidAppear.value = true
+
// Deprecated:
// not needs this tweak due to force layout update in the parent
// if !isAdjustBannerImageViewForSafeAreaInset {
@@ -85,11 +213,52 @@ extension ProfileHeaderViewController {
super.viewDidLayoutSubviews()
delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view)
- view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero)
+ setupBottomShadow()
}
}
+extension ProfileHeaderViewController {
+ private func createAvatarContextMenu() -> UIMenu {
+ var children: [UIMenuElement] = []
+ let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
+ guard let self = self else { return }
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function)
+ self.present(self.imagePicker, animated: true, completion: nil)
+ }
+ children.append(photoLibraryAction)
+ if UIImagePickerController.isSourceTypeAvailable(.camera) {
+ let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
+ guard let self = self else { return }
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function)
+ self.present(self.imagePickerController, animated: true, completion: nil)
+ })
+ children.append(cameraAction)
+ }
+ let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
+ guard let self = self else { return }
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function)
+ self.present(self.documentPickerController, animated: true, completion: nil)
+ }
+ children.append(browseAction)
+
+ return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
+ }
+
+ private func cropImage(image: UIImage, pickerViewController: UIViewController) {
+ DispatchQueue.main.async {
+ let cropController = CropViewController(croppingStyle: .default, image: image)
+ cropController.delegate = self
+ cropController.setAspectRatioPreset(.presetSquare, animated: true)
+ cropController.aspectRatioPickerButtonHidden = true
+ cropController.aspectRatioLockEnabled = true
+ pickerViewController.dismiss(animated: true, completion: {
+ self.present(cropController, animated: true, completion: nil)
+ })
+ }
+ }
+}
+
extension ProfileHeaderViewController {
@objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) {
@@ -105,6 +274,15 @@ extension ProfileHeaderViewController {
containerSafeAreaInset = inset
}
+ func setupBottomShadow() {
+ guard viewModel.needsSetupBottomShadow.value else {
+ view.layer.shadowColor = nil
+ view.layer.shadowRadius = 0
+ return
+ }
+ view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero)
+ }
+
private func updateHeaderBottomShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1)
if bottomShadowAlpha != alpha {
@@ -125,20 +303,133 @@ extension ProfileHeaderViewController {
let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil)
let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height
-
+
+ // scroll from bottom to top: 1 -> 2 -> 3
if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {
+ // 1
+ // banner top pin to window top and expand
bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y
bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height
} else if bannerContainerBottomOffset < containerSafeAreaInset.top {
+ // 3
+ // banner bottom pin to navigation bar bottom and
+ // the `progress` growth to 1 then segemented control pin to top
bannerImageView.frame.origin.y = -containerSafeAreaInset.top
let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset)
bannerImageView.frame.size.height = bannerImageHeight
} else {
+ // 2
+ // banner move with scrolling from bottom to top until the
+ // banner bottom higher than navigation bar bottom
bannerImageView.frame.origin.y = -containerSafeAreaInset.top
bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top
}
- // TODO: handle titleView
+ // set title view offset
+ let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
+ let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y
+ let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset
+ titleView.containerView.transform = CGAffineTransform(translationX: 0, y: max(0, titleViewContentOffset))
+
+ if viewModel.viewDidAppear.value {
+ viewModel.isTitleViewContentOffsetSet.value = true
+ }
+
+ // set avatar
+ if progress > 0 {
+ setProfileBannerFade(alpha: 0)
+ } else if progress > -0.3 {
+ // y = -(10/3)x
+ let alpha = -10.0 / 3.0 * progress
+ setProfileBannerFade(alpha: alpha)
+ } else {
+ setProfileBannerFade(alpha: 1)
+ }
+ }
+
+ private func setProfileBannerFade(alpha: CGFloat) {
+ profileHeaderView.avatarImageView.alpha = alpha
+ profileHeaderView.editAvatarBackgroundView.alpha = alpha
+ profileHeaderView.nameTextFieldBackgroundView.alpha = alpha
+ profileHeaderView.nameTextField.alpha = alpha
+ profileHeaderView.usernameLabel.alpha = alpha
}
}
+
+// MARK: - TextEditorViewChangeObserver
+extension ProfileHeaderViewController: TextEditorViewChangeObserver {
+ func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text)
+ guard changeResult.isTextChanged else { return }
+ assert(textEditorView === profileHeaderView.bioTextEditorView)
+ viewModel.editProfileInfo.note.value = textEditorView.text
+ }
+}
+
+// MARK: - PHPickerViewControllerDelegate
+extension ProfileHeaderViewController: PHPickerViewControllerDelegate {
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ picker.dismiss(animated: true, completion: nil)
+ guard let result = results.first else { return }
+ PHPickerResultLoader.loadImageData(from: result)
+ .sink { [weak self] completion in
+ guard let _ = self else { return }
+ switch completion {
+ case .failure:
+ // TODO: handle error
+ break
+ case .finished:
+ break
+ }
+ } receiveValue: { [weak self] imageData in
+ guard let self = self else { return }
+ guard let imageData = imageData else { return }
+ guard let image = UIImage(data: imageData) else { return }
+ self.cropImage(image: image, pickerViewController: picker)
+ }
+ .store(in: &disposeBag)
+ }
+}
+
+// MARK: - UIImagePickerControllerDelegate
+extension ProfileHeaderViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate {
+
+ func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
+ picker.dismiss(animated: true, completion: nil)
+
+ guard let image = info[.originalImage] as? UIImage else { return }
+ cropImage(image: image, pickerViewController: picker)
+ }
+
+ func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
+ os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ picker.dismiss(animated: true, completion: nil)
+ }
+}
+
+// MARK: - UIDocumentPickerDelegate
+extension ProfileHeaderViewController: UIDocumentPickerDelegate {
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
+ guard let url = urls.first else { return }
+
+ do {
+ guard url.startAccessingSecurityScopedResource() else { return }
+ defer { url.stopAccessingSecurityScopedResource() }
+ let imageData = try Data(contentsOf: url)
+ guard let image = UIImage(data: imageData) else { return }
+ cropImage(image: image, pickerViewController: controller)
+ } catch {
+ os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
+ }
+ }
+}
+
+// MARK: - CropViewControllerDelegate
+extension ProfileHeaderViewController: CropViewControllerDelegate {
+ public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) {
+ viewModel.editProfileInfo.avatarImageResource.value = .image(image)
+ cropViewController.dismiss(animated: true, completion: nil)
+ }
+}
+
diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift
new file mode 100644
index 000000000..eb4a054b8
--- /dev/null
+++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift
@@ -0,0 +1,115 @@
+//
+// ProfileHeaderViewModel.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-9.
+//
+
+import UIKit
+import Combine
+import Kanna
+import MastodonSDK
+
+final class ProfileHeaderViewModel {
+
+ var disposeBag = Set()
+
+ // input
+ let context: AppContext
+ let isEditing = CurrentValueSubject(false)
+ let viewDidAppear = CurrentValueSubject(false)
+ let needsSetupBottomShadow = CurrentValueSubject(true)
+ let isTitleViewContentOffsetSet = CurrentValueSubject(false)
+
+ // output
+ let displayProfileInfo = ProfileInfo()
+ let editProfileInfo = ProfileInfo()
+
+ init(context: AppContext) {
+ self.context = context
+
+ isEditing
+ .removeDuplicates() // only triiger when value toggle
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isEditing in
+ guard let self = self else { return }
+ // setup editing value when toggle to editing
+ self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
+ self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
+ self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value)
+ }
+ .store(in: &disposeBag)
+ }
+
+}
+
+extension ProfileHeaderViewModel {
+ struct ProfileInfo {
+ let name = CurrentValueSubject(nil)
+ let avatarImageResource = CurrentValueSubject(nil)
+ let note = CurrentValueSubject(nil)
+
+ enum ImageResource {
+ case url(URL?)
+ case image(UIImage?)
+ }
+ }
+}
+
+extension ProfileHeaderViewModel {
+
+ static func normalize(note: String?) -> String? {
+ guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else {
+ return nil
+ }
+
+ let html = try? HTML(html: note, encoding: .utf8)
+ return html?.text
+ }
+
+ // check if profile chagned or not
+ func isProfileInfoEdited() -> Bool {
+ guard isEditing.value else { return false }
+
+ guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true }
+ guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true }
+ guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true }
+
+ return false
+ }
+
+ func updateProfileInfo() -> AnyPublisher, Error> {
+ guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return Fail(error: APIService.APIError.implicit(.badRequest)).eraseToAnyPublisher()
+ }
+ let domain = activeMastodonAuthenticationBox.domain
+ let authorization = activeMastodonAuthenticationBox.userAuthorization
+
+ let image: UIImage? = {
+ guard case let .image(_image) = editProfileInfo.avatarImageResource.value else { return nil }
+ guard let image = _image else { return nil }
+ guard image.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else {
+ return image.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel)
+ }
+ return image
+ }()
+
+ let query = Mastodon.API.Account.UpdateCredentialQuery(
+ discoverable: nil,
+ bot: nil,
+ displayName: editProfileInfo.name.value,
+ note: editProfileInfo.note.value,
+ avatar: image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) },
+ header: nil,
+ locked: nil,
+ source: nil,
+ fieldsAttributes: nil // TODO:
+ )
+ return context.apiService.accountUpdateCredentials(
+ domain: domain,
+ query: query,
+ authorization: authorization
+ )
+ }
+
+}
diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
index bf292ac45..2fba55e66 100644
--- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
+++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
@@ -8,6 +8,7 @@
import os.log
import UIKit
import ActiveLabel
+import TwitterTextEditor
protocol ProfileHeaderViewDelegate: class {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
@@ -25,8 +26,13 @@ final class ProfileHeaderView: UIView {
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
static let bannerImageViewPlaceholderColor = UIColor.systemGray
+ static let bannerImageViewOverlayViewBackgroundNormalColor = UIColor.black.withAlphaComponent(0.5)
+ static let bannerImageViewOverlayViewBackgroundEditingColor = UIColor.black.withAlphaComponent(0.8)
+
weak var delegate: ProfileHeaderViewDelegate?
+ var state: State?
+
let bannerContainerView = UIView()
let bannerImageView: UIImageView = {
let imageView = UIImageView()
@@ -41,7 +47,7 @@ final class ProfileHeaderView: UIView {
}()
let bannerImageViewOverlayView: UIView = {
let overlayView = UIView()
- overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
+ overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
return overlayView
}()
@@ -53,16 +59,40 @@ final class ProfileHeaderView: UIView {
imageView.image = placeholderImage
return imageView
}()
+
+ let editAvatarBackgroundView: UIView = {
+ let view = UIView()
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.6)
+ view.layer.masksToBounds = true
+ view.layer.cornerCurve = .continuous
+ view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
+ return view
+ }()
+
+ let editAvatarButton: HighlightDimmableButton = {
+ let button = HighlightDimmableButton()
+ button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal)
+ button.tintColor = .white
+ return button
+ }()
- let nameLabel: UILabel = {
- let label = UILabel()
- label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
- label.adjustsFontSizeToFitWidth = true
- label.minimumScaleFactor = 0.5
- label.textColor = .white
- label.text = "Alice"
- label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
- return label
+ let nameTextFieldBackgroundView: UIView = {
+ let view = UIView()
+ view.layer.masksToBounds = true
+ view.layer.cornerCurve = .continuous
+ view.layer.cornerRadius = 10
+ return view
+ }()
+
+ let nameTextField: UITextField = {
+ let textField = UITextField()
+ textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
+ textField.textColor = .white
+ textField.text = "Alice"
+ textField.autocorrectionType = .no
+ textField.autocapitalizationType = .none
+ textField.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
+ return textField
}()
let usernameLabel: UILabel = {
@@ -84,9 +114,29 @@ final class ProfileHeaderView: UIView {
}()
let bioContainerView = UIView()
+ let bioContainerStackView = UIStackView()
let fieldContainerStackView = UIStackView()
+ let bioActiveLabelContainer: UIView = {
+ // use to set margin for active label
+ // the display/edit mode bio transition animation should without flicker with that
+ let view = UIView()
+ // note: comment out to see how it works
+ view.layoutMargins = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) // magic from TextEditorView
+ return view
+ }()
let bioActiveLabel = ActiveLabel(style: .default)
+ let bioTextEditorView: TextEditorView = {
+ let textEditorView = TextEditorView()
+ textEditorView.scrollView.isScrollEnabled = false
+ textEditorView.isScrollEnabled = false
+ textEditorView.font = .preferredFont(forTextStyle: .body)
+ textEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color
+ textEditorView.layer.masksToBounds = true
+ textEditorView.layer.cornerCurve = .continuous
+ textEditorView.layer.cornerRadius = 10
+ return textEditorView
+ }()
override init(frame: CGRect) {
super.init(frame: frame)
@@ -137,12 +187,32 @@ extension ProfileHeaderView {
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
])
+
+ editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
+ avatarImageView.addSubview(editAvatarBackgroundView)
+ NSLayoutConstraint.activate([
+ editAvatarBackgroundView.topAnchor.constraint(equalTo: avatarImageView.topAnchor),
+ editAvatarBackgroundView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor),
+ editAvatarBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
+ editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
+ ])
+
+ editAvatarButton.translatesAutoresizingMaskIntoConstraints = false
+ editAvatarBackgroundView.addSubview(editAvatarButton)
+ NSLayoutConstraint.activate([
+ editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor),
+ editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor),
+ editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor),
+ editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor),
+ ])
+ editAvatarBackgroundView.isUserInteractionEnabled = true
+ avatarImageView.isUserInteractionEnabled = true
- // name container: [display name | username]
+ // name container: [display name container | username]
let nameContainerStackView = UIStackView()
nameContainerStackView.preservesSuperviewLayoutMargins = true
nameContainerStackView.axis = .vertical
- nameContainerStackView.spacing = 0
+ nameContainerStackView.spacing = 7
nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(nameContainerStackView)
NSLayoutConstraint.activate([
@@ -150,7 +220,27 @@ extension ProfileHeaderView {
nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor),
])
- nameContainerStackView.addArrangedSubview(nameLabel)
+
+ let displayNameStackView = UIStackView()
+ displayNameStackView.axis = .horizontal
+ nameTextField.translatesAutoresizingMaskIntoConstraints = false
+ displayNameStackView.addArrangedSubview(nameTextField)
+ NSLayoutConstraint.activate([
+ nameTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
+ ])
+ nameTextField.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+ nameTextFieldBackgroundView.translatesAutoresizingMaskIntoConstraints = false
+ displayNameStackView.addSubview(nameTextFieldBackgroundView)
+ NSLayoutConstraint.activate([
+ nameTextField.topAnchor.constraint(equalTo: nameTextFieldBackgroundView.topAnchor, constant: 5),
+ nameTextField.leadingAnchor.constraint(equalTo: nameTextFieldBackgroundView.leadingAnchor, constant: 5),
+ nameTextFieldBackgroundView.bottomAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 5),
+ nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor, constant: 5),
+ ])
+ displayNameStackView.bringSubviewToFront(nameTextField)
+ displayNameStackView.addArrangedSubview(UIView())
+
+ nameContainerStackView.addArrangedSubview(displayNameStackView)
nameContainerStackView.addArrangedSubview(usernameLabel)
// meta container: [dashboard container | bio container | field container]
@@ -192,15 +282,29 @@ extension ProfileHeaderView {
bioContainerView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addArrangedSubview(bioContainerView)
- bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false
- bioContainerView.addSubview(bioActiveLabel)
+
+ bioContainerStackView.translatesAutoresizingMaskIntoConstraints = false
+ bioContainerView.addSubview(bioContainerStackView)
NSLayoutConstraint.activate([
- bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor),
- bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor),
- bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor),
- bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor),
+ bioContainerStackView.topAnchor.constraint(equalTo: bioContainerView.topAnchor),
+ bioContainerStackView.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor),
+ bioContainerStackView.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor),
+ bioContainerStackView.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor),
])
+ bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false
+ bioActiveLabelContainer.addSubview(bioActiveLabel)
+ NSLayoutConstraint.activate([
+ bioActiveLabel.topAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.topAnchor),
+ bioActiveLabel.leadingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.leadingAnchor),
+ bioActiveLabel.trailingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.trailingAnchor),
+ bioActiveLabel.bottomAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.bottomAnchor),
+ ])
+
+ bioContainerStackView.axis = .vertical
+ bioContainerStackView.addArrangedSubview(bioActiveLabelContainer)
+ bioContainerStackView.addArrangedSubview(bioTextEditorView)
+
fieldContainerStackView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addSubview(fieldContainerStackView)
@@ -210,10 +314,58 @@ extension ProfileHeaderView {
bioActiveLabel.delegate = self
relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside)
+
+ configure(state: .normal)
}
}
+extension ProfileHeaderView {
+ enum State {
+ case normal
+ case editing
+ }
+
+ func configure(state: State) {
+ guard self.state != state else { return } // avoid redundant animation
+ self.state = state
+
+ let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
+
+ switch state {
+ case .normal:
+ nameTextField.isEnabled = false
+ bioActiveLabelContainer.isHidden = false
+ bioTextEditorView.isHidden = true
+
+ animator.addAnimations {
+ self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
+ self.nameTextFieldBackgroundView.backgroundColor = .clear
+ self.editAvatarBackgroundView.alpha = 0
+ }
+ animator.addCompletion { _ in
+ self.editAvatarBackgroundView.isHidden = true
+ }
+ case .editing:
+ nameTextField.isEnabled = true
+ bioActiveLabelContainer.isHidden = true
+ bioTextEditorView.isHidden = false
+
+ editAvatarBackgroundView.isHidden = false
+ editAvatarBackgroundView.alpha = 0
+ bioTextEditorView.backgroundColor = .clear
+ animator.addAnimations {
+ self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor
+ self.nameTextFieldBackgroundView.backgroundColor = Asset.Profile.Banner.nameEditBackgroundGray.color
+ self.editAvatarBackgroundView.alpha = 1
+ self.bioTextEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color
+ }
+ }
+
+ animator.startAnimation()
+ }
+}
+
extension ProfileHeaderView {
@objc private func relationshipActionButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift
index 0f6a804b5..d4b57ffe4 100644
--- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift
+++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift
@@ -9,6 +9,12 @@ import UIKit
final class ProfileRelationshipActionButton: RoundedEdgesButton {
+ let actvityIndicatorView: UIActivityIndicatorView = {
+ let activityIndicatorView = UIActivityIndicatorView(style: .medium)
+ activityIndicatorView.color = .white
+ return activityIndicatorView
+ }()
+
override init(frame: CGRect) {
super.init(frame: frame)
_init()
@@ -23,7 +29,15 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton {
extension ProfileRelationshipActionButton {
private func _init() {
- // do nothing
+ actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(actvityIndicatorView)
+ NSLayoutConstraint.activate([
+ actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
+ actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
+ ])
+
+ actvityIndicatorView.hidesWhenStopped = true
+ actvityIndicatorView.stopAnimating()
}
}
@@ -34,10 +48,15 @@ extension ProfileRelationshipActionButton {
setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
- setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .disabled)
+ setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
- if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked {
+ actvityIndicatorView.stopAnimating()
+
+ if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended {
isEnabled = false
+ } else if actionOptionSet.contains(.updating) {
+ isEnabled = false
+ actvityIndicatorView.startAnimating()
} else {
isEnabled = true
}
diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift
index 36bcf0b4e..d1c0cb49d 100644
--- a/Mastodon/Scene/Profile/MeProfileViewModel.swift
+++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift
@@ -22,7 +22,7 @@ final class MeProfileViewModel: ProfileViewModel {
self.currentMastodonUser
.sink { [weak self] currentMastodonUser in
- os_log("%{public}s[%{public}ld], %{public}s: current active twitter user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "")
+ os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "")
guard let self = self else { return }
self.mastodonUser.value = currentMastodonUser
diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift
index 59cf4809f..671f7c155 100644
--- a/Mastodon/Scene/Profile/ProfileViewController.swift
+++ b/Mastodon/Scene/Profile/ProfileViewController.swift
@@ -18,6 +18,30 @@ final class ProfileViewController: UIViewController, NeedsDependency {
var disposeBag = Set()
var viewModel: ProfileViewModel!
+ private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = {
+ let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:)))
+ barButtonItem.tintColor = .white
+ return barButtonItem
+ }()
+
+ private(set) lazy var settingBarButtonItem: UIBarButtonItem = {
+ let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:)))
+ barButtonItem.tintColor = .white
+ return barButtonItem
+ }()
+
+ private(set) lazy var shareBarButtonItem: UIBarButtonItem = {
+ let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(ProfileViewController.shareBarButtonItemPressed(_:)))
+ barButtonItem.tintColor = .white
+ return barButtonItem
+ }()
+
+ private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = {
+ let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "star"), style: .plain, target: self, action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:)))
+ barButtonItem.tintColor = .white
+ return barButtonItem
+ }()
+
private(set) lazy var replyBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:)))
barButtonItem.tintColor = .white
@@ -54,12 +78,20 @@ final class ProfileViewController: UIViewController, NeedsDependency {
}()
private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController()
- private(set) lazy var profileHeaderViewController = ProfileHeaderViewController()
+ private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = {
+ let viewController = ProfileHeaderViewController()
+ viewController.viewModel = ProfileHeaderViewModel(context: context)
+ return viewController
+ }()
private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint!
private var contentOffsets: [Int: CGFloat] = [:]
var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation?
+ // title view nested in header
+ var titleView: DoubleTitleLabelNavigationBarTitleView {
+ profileHeaderViewController.titleView
+ }
deinit {
os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function)
@@ -116,27 +148,66 @@ extension ProfileViewController {
navigationItem.compactAppearance = barAppearance
navigationItem.scrollEdgeAppearance = barAppearance
- navigationItem.titleView = UIView()
+ navigationItem.titleView = titleView
+
+ let editingAndUpdatingPublisher = Publishers.CombineLatest(
+ viewModel.isEditing.eraseToAnyPublisher(),
+ viewModel.isUpdating.eraseToAnyPublisher()
+ )
+ // note: not add .share() here
- Publishers.CombineLatest(
+ let barButtonItemHiddenPublisher = Publishers.CombineLatest3(
+ viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(),
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
)
+
+ editingAndUpdatingPublisher
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isEditing, isUpdating in
+ guard let self = self else { return }
+ self.cancelEditingBarButtonItem.isEnabled = !isUpdating
+ }
+ .store(in: &disposeBag)
+
+ Publishers.CombineLatest3 (
+ viewModel.suspended.eraseToAnyPublisher(),
+ editingAndUpdatingPublisher.eraseToAnyPublisher(),
+ barButtonItemHiddenPublisher.eraseToAnyPublisher()
+ )
.receive(on: DispatchQueue.main)
- .sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
+ .sink { [weak self] suspended, tuple1, tuple2 in
guard let self = self else { return }
+ let (isEditing, _) = tuple1
+ let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2
+
var items: [UIBarButtonItem] = []
+ defer {
+ self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil
+ }
+
+ guard !suspended else {
+ return
+ }
+
+ guard !isEditing else {
+ items.append(self.cancelEditingBarButtonItem)
+ return
+ }
+
+ guard isMeBarButtonItemsHidden else {
+ items.append(self.settingBarButtonItem)
+ items.append(self.shareBarButtonItem)
+ items.append(self.favoriteBarButtonItem)
+ return
+ }
+
if !isReplyBarButtonItemHidden {
items.append(self.replyBarButtonItem)
}
if !isMoreMenuBarButtonItemHidden {
items.append(self.moreMenuBarButtonItem)
}
- guard !items.isEmpty else {
- self.navigationItem.rightBarButtonItems = nil
- return
- }
- self.navigationItem.rightBarButtonItems = items
}
.store(in: &disposeBag)
@@ -225,6 +296,23 @@ extension ProfileViewController {
profileSegmentedViewController.pagingViewController.pagingDelegate = self
// bind view model
+ Publishers.CombineLatest(
+ viewModel.name.eraseToAnyPublisher(),
+ viewModel.statusesCount.eraseToAnyPublisher()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] name, statusesCount in
+ guard let self = self else { return }
+ guard let title = name, let statusesCount = statusesCount,
+ let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else {
+ self.titleView.isHidden = true
+ return
+ }
+ let subtitle = L10n.Scene.Profile.subtitle(formattedStatusCount)
+ self.titleView.update(title: title, subtitle: subtitle)
+ self.titleView.isHidden = false
+ }
+ .store(in: &disposeBag)
viewModel.name
.receive(on: DispatchQueue.main)
.sink { [weak self] name in
@@ -263,22 +351,15 @@ extension ProfileViewController {
)
}
.store(in: &disposeBag)
- Publishers.CombineLatest(
- viewModel.avatarImageURL.eraseToAnyPublisher(),
- viewModel.viewDidAppear.eraseToAnyPublisher()
- )
- .receive(on: DispatchQueue.main)
- .sink { [weak self] avatarImageURL, _ in
- guard let self = self else { return }
- self.profileHeaderViewController.profileHeaderView.configure(
- with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2)
- )
- }
- .store(in: &disposeBag)
- viewModel.name
- .map { $0 ?? " " }
+ viewModel.avatarImageURL
.receive(on: DispatchQueue.main)
- .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel)
+ .map { url in ProfileHeaderViewModel.ProfileInfo.ImageResource.url(url) }
+ .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.avatarImageResource)
+ .store(in: &disposeBag)
+ viewModel.name
+ .map { $0 ?? "" }
+ .receive(on: DispatchQueue.main)
+ .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
.store(in: &disposeBag)
viewModel.username
.map { username in username.flatMap { "@" + $0 } ?? " " }
@@ -295,7 +376,8 @@ extension ProfileViewController {
}
let isMuting = relationshipActionOptionSet.contains(.muting)
let isBlocking = relationshipActionOptionSet.contains(.blocking)
- self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self)
+ let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value
+ self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, needsShareAction: needsShareAction, provider: self, sourceView: nil, barButtonItem: self.moreMenuBarButtonItem)
}
.store(in: &disposeBag)
viewModel.isRelationshipActionButtonHidden
@@ -305,27 +387,60 @@ extension ProfileViewController {
self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden
}
.store(in: &disposeBag)
- Publishers.CombineLatest(
+ Publishers.CombineLatest3(
viewModel.relationshipActionOptionSet.eraseToAnyPublisher(),
- viewModel.isEditing.eraseToAnyPublisher()
+ viewModel.isEditing.eraseToAnyPublisher(),
+ viewModel.isUpdating.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
- .sink { [weak self] relationshipActionSet, isEditing in
+ .sink { [weak self] relationshipActionSet, isEditing, isUpdating in
guard let self = self else { return }
let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton
if relationshipActionSet.contains(.edit) {
- friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit)
+ // check .edit state and set .editing when isEditing
+ friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit))
+ self.profileHeaderViewController.profileHeaderView.configure(state: isUpdating || isEditing ? .editing : .normal)
} else {
friendshipButton.configure(actionOptionSet: relationshipActionSet)
}
}
.store(in: &disposeBag)
+ viewModel.isEditing
+ .handleEvents(receiveOutput: { [weak self] isEditing in
+ guard let self = self else { return }
+ // dismiss keyboard if needs
+ if !isEditing { self.view.endEditing(true) }
+
+ self.profileHeaderViewController.pageSegmentedControl.isEnabled = !isEditing
+ self.profileSegmentedViewController.view.isUserInteractionEnabled = !isEditing
+
+ let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
+ animator.addAnimations {
+ self.profileSegmentedViewController.view.alpha = isEditing ? 0.2 : 1.0
+ self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0
+ }
+ animator.startAnimation()
+ })
+ .assign(to: \.value, on: profileHeaderViewController.viewModel.isEditing)
+ .store(in: &disposeBag)
+ Publishers.CombineLatest3(
+ viewModel.isBlocking.eraseToAnyPublisher(),
+ viewModel.isBlockedBy.eraseToAnyPublisher(),
+ viewModel.suspended.eraseToAnyPublisher()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isBlocking, isBlockedBy, suspended in
+ guard let self = self else { return }
+ let isNeedSetHidden = isBlocking || isBlockedBy || suspended
+ self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden
+ self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden
+ self.profileHeaderViewController.pageSegmentedControl.isHidden = isNeedSetHidden
+ self.viewModel.needsPagePinToTop.value = isNeedSetHidden
+ }
+ .store(in: &disposeBag)
viewModel.bioDescription
.receive(on: DispatchQueue.main)
- .sink(receiveValue: { [weak self] bio in
- guard let self = self else { return }
- self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "")
- })
+ .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.note)
.store(in: &disposeBag)
viewModel.statusesCount
.sink { [weak self] count in
@@ -374,6 +489,7 @@ extension ProfileViewController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
+
currentPostTimelineTableViewContentSizeObservation = nil
}
@@ -386,12 +502,45 @@ extension ProfileViewController {
viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag)
viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag)
viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag)
+ viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag)
+ viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag)
}
}
extension ProfileViewController {
+ @objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ viewModel.isEditing.value = false
+ }
+
+ @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+
+ }
+
+ @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ guard let mastodonUser = viewModel.mastodonUser.value else { return }
+ let activityViewController = UserProviderFacade.createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: self)
+ coordinator.present(
+ scene: .activityViewController(
+ activityViewController: activityViewController,
+ sourceView: nil,
+ barButtonItem: sender
+ ),
+ from: self,
+ transition: .activityViewControllerPresent(animated: true, completion: nil)
+ )
+ }
+
+ @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ let favoriteViewModel = FavoriteViewModel(context: context)
+ coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show)
+ }
+
@objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let mastodonUser = viewModel.mastodonUser.value else { return }
@@ -414,11 +563,6 @@ extension ProfileViewController {
sender.endRefreshing()
}
}
-
-// @objc private func avatarButtonPressed(_ sender: UIButton) {
-// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
-// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController))
-// }
}
@@ -436,10 +580,15 @@ extension ProfileViewController: UIScrollViewDelegate {
contentOffsets.removeAll()
} else {
containerScrollView.contentOffset.y = topMaxContentOffsetY
- if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer {
- let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y
- customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY
+ if viewModel.needsPagePinToTop.value {
+ // do nothing
+ } else {
+ if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer {
+ let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y
+ customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY
+ }
}
+
}
// elastically banner image
@@ -492,22 +641,42 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
let relationshipActionSet = viewModel.relationshipActionOptionSet.value
if relationshipActionSet.contains(.edit) {
- viewModel.isEditing.value.toggle()
+ guard !viewModel.isUpdating.value else { return }
+
+ if profileHeaderViewController.viewModel.isProfileInfoEdited() {
+ viewModel.isUpdating.value = true
+ profileHeaderViewController.viewModel.updateProfileInfo()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] completion in
+ guard let self = self else { return }
+ switch completion {
+ case .failure(let error):
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
+ case .finished:
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info success", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+ self.viewModel.isUpdating.value = false
+ } receiveValue: { [weak self] _ in
+ guard let self = self else { return }
+ self.viewModel.isEditing.value = false
+ }
+ .store(in: &disposeBag)
+ } else {
+ viewModel.isEditing.value.toggle()
+ }
} else {
guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return }
switch relationshipAction {
case .none:
break
- case .follow, .following:
+ case .follow, .reqeust, .pending, .following:
UserProviderFacade.toggleUserFollowRelationship(provider: self)
.sink { _ in
-
+ // TODO: handle error
} receiveValue: { _ in
-
+ // do nothing
}
.store(in: &disposeBag)
- case .pending:
- break
case .muting:
guard let mastodonUser = viewModel.mastodonUser.value else { return }
let name = mastodonUser.displayNameWithFallback
@@ -557,9 +726,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
default:
assertionFailure()
}
-
}
-
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) {
diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift
index 057e18030..445952e96 100644
--- a/Mastodon/Scene/Profile/ProfileViewModel.swift
+++ b/Mastodon/Scene/Profile/ProfileViewModel.swift
@@ -41,10 +41,12 @@ class ProfileViewModel: NSObject {
let followersCount: CurrentValueSubject
let protected: CurrentValueSubject
- // let suspended: CurrentValueSubject
+ let suspended: CurrentValueSubject
- let relationshipActionOptionSet = CurrentValueSubject(.none)
let isEditing = CurrentValueSubject(false)
+ let isUpdating = CurrentValueSubject(false)
+
+ let relationshipActionOptionSet = CurrentValueSubject(.none)
let isFollowedBy = CurrentValueSubject(false)
let isMuting = CurrentValueSubject(false)
let isBlocking = CurrentValueSubject(false)
@@ -53,6 +55,9 @@ class ProfileViewModel: NSObject {
let isRelationshipActionButtonHidden = CurrentValueSubject(true)
let isReplyBarButtonItemHidden = CurrentValueSubject(true)
let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true)
+ let isMeBarButtonItemsHidden = CurrentValueSubject(true)
+
+ let needsPagePinToTop = CurrentValueSubject(false)
init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
self.context = context
@@ -61,7 +66,6 @@ class ProfileViewModel: NSObject {
self.userID = CurrentValueSubject(mastodonUser?.id)
self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL())
self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL())
-// self.protected = CurrentValueSubject(twitterUser?.protected)
self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback)
self.username = CurrentValueSubject(mastodonUser?.acctWithDomain)
self.bioDescription = CurrentValueSubject(mastodonUser?.note)
@@ -70,6 +74,7 @@ class ProfileViewModel: NSObject {
self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) })
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
self.protected = CurrentValueSubject(mastodonUser?.locked)
+ self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
super.init()
relationshipActionOptionSet
@@ -225,6 +230,7 @@ extension ProfileViewModel {
self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) }
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
self.protected.value = mastodonUser?.locked
+ self.suspended.value = mastodonUser?.suspended ?? false
}
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
@@ -240,6 +246,7 @@ extension ProfileViewModel {
// set bar button item state
self.isReplyBarButtonItemHidden.value = true
self.isMoreMenuBarButtonItemHidden.value = true
+ self.isMeBarButtonItemsHidden.value = true
return
}
@@ -248,10 +255,19 @@ extension ProfileViewModel {
// set bar button item state
self.isReplyBarButtonItemHidden.value = true
self.isMoreMenuBarButtonItemHidden.value = true
+ self.isMeBarButtonItemsHidden.value = false
} else {
// set with follow action default
var relationshipActionSet = RelationshipActionOptionSet([.follow])
+ if mastodonUser.locked {
+ relationshipActionSet.insert(.request)
+ }
+
+ if mastodonUser.suspended {
+ relationshipActionSet.insert(.suspended)
+ }
+
let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isFollowing {
relationshipActionSet.insert(.following)
@@ -294,6 +310,7 @@ extension ProfileViewModel {
// set bar button item state
self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy
self.isMoreMenuBarButtonItemHidden.value = false
+ self.isMeBarButtonItemsHidden.value = true
}
}
@@ -304,13 +321,16 @@ extension ProfileViewModel {
enum RelationshipAction: Int, CaseIterable {
case none // set hide from UI
case follow
+ case reqeust
case pending
case following
case muting
case blocked
case blocking
+ case suspended
case edit
case editing
+ case updating
var option: RelationshipActionOptionSet {
return RelationshipActionOptionSet(rawValue: 1 << rawValue)
@@ -323,15 +343,18 @@ extension ProfileViewModel {
static let none = RelationshipAction.none.option
static let follow = RelationshipAction.follow.option
+ static let request = RelationshipAction.reqeust.option
static let pending = RelationshipAction.pending.option
static let following = RelationshipAction.following.option
static let muting = RelationshipAction.muting.option
static let blocked = RelationshipAction.blocked.option
static let blocking = RelationshipAction.blocking.option
+ static let suspended = RelationshipAction.suspended.option
static let edit = RelationshipAction.edit.option
static let editing = RelationshipAction.editing.option
+ static let updating = RelationshipAction.updating.option
- static let editOptions: RelationshipActionOptionSet = [.edit, .editing]
+ static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating]
func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? {
let set = subtracting(except)
@@ -350,13 +373,16 @@ extension ProfileViewModel {
switch highPriorityAction {
case .none: return " "
case .follow: return L10n.Common.Controls.Firendship.follow
+ case .reqeust: return L10n.Common.Controls.Firendship.request
case .pending: return L10n.Common.Controls.Firendship.pending
case .following: return L10n.Common.Controls.Firendship.following
case .muting: return L10n.Common.Controls.Firendship.muted
case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user
case .blocking: return L10n.Common.Controls.Firendship.blocked
+ case .suspended: return L10n.Common.Controls.Firendship.follow
case .edit: return L10n.Common.Controls.Firendship.editInfo
case .editing: return L10n.Common.Controls.Actions.done
+ case .updating: return " "
}
}
@@ -368,13 +394,16 @@ extension ProfileViewModel {
switch highPriorityAction {
case .none: return Asset.Colors.Button.normal.color
case .follow: return Asset.Colors.Button.normal.color
+ case .reqeust: return Asset.Colors.Button.normal.color
case .pending: return Asset.Colors.Button.normal.color
case .following: return Asset.Colors.Button.normal.color
case .muting: return Asset.Colors.Background.alertYellow.color
- case .blocked: return Asset.Colors.Button.disabled.color
+ case .blocked: return Asset.Colors.Button.normal.color
case .blocking: return Asset.Colors.Background.danger.color
+ case .suspended: return Asset.Colors.Button.normal.color
case .edit: return Asset.Colors.Button.normal.color
case .editing: return Asset.Colors.Button.normal.color
+ case .updating: return Asset.Colors.Button.normal.color
}
}
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift
index 442f57cce..e8e71ccf4 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift
@@ -57,6 +57,7 @@ extension UserTimelineViewController {
])
tableView.delegate = self
+ tableView.prefetchDataSource = self
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
@@ -80,21 +81,31 @@ extension UserTimelineViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
- tableView.deselectRow(with: transitionCoordinator, animated: animated)
+ aspectViewWillAppear(animated)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
- context.videoPlaybackService.viewDidDisappear(from: self)
+ aspectViewDidDisappear(animated)
}
}
+// MARK: - StatusTableViewControllerAspect
+extension UserTimelineViewController: StatusTableViewControllerAspect { }
+
// MARK: - UIScrollViewDelegate
extension UserTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
- handleScrollViewDidScroll(scrollView)
+ aspectScrollViewDidScroll(scrollView)
+ }
+}
+
+// MARK: - TableViewCellHeightCacheableContainer
+extension UserTimelineViewController: TableViewCellHeightCacheableContainer {
+ var cellFrameCache: NSCache {
+ return viewModel.cellFrameCache
}
}
@@ -102,41 +113,35 @@ extension UserTimelineViewController {
extension UserTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
- guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
- guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
-
- guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
- if case .bottomLoader = item {
- return TimelineLoaderTableViewCell.cellHeight
- } else {
- return 200
- }
- }
- // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
-
- return ceil(frame.height)
+ aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
- guard let diffableDataSource = viewModel.diffableDataSource else { return }
- guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
-
- let key = item.hashValue
- let frame = cell.frame
- viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
+ aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
}
+// MARK: - UITableViewDataSourcePrefetching
+extension UserTimelineViewController: UITableViewDataSourcePrefetching {
+ func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
+ aspectTableView(tableView, prefetchRowsAt: indexPaths)
+ }
+}
+
// MARK: - AVPlayerViewControllerDelegate
extension UserTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
- handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
+ aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
- handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
+ aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
@@ -147,10 +152,6 @@ extension UserTimelineViewController: StatusTableViewCellDelegate {
func parent() -> UIViewController { return self }
}
-//// MARK: - TimelineHeaderTableViewCellDelegate
-//extension UserTimelineViewController: TimelineHeaderTableViewCellDelegate { }
-
-
// MARK: - CustomScrollViewContainerController
extension UserTimelineViewController: ScrollViewContainer {
var scrollView: UIScrollView { return tableView }
@@ -159,7 +160,7 @@ extension UserTimelineViewController: ScrollViewContainer {
// MARK: - LoadMoreConfigurableTableViewContainer
extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
- typealias LoadingState = UserTimelineViewModel.State.LoadingMore
+ typealias LoadingState = UserTimelineViewModel.State.Loading
var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift
index f31f52400..be06d781a 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift
@@ -40,11 +40,7 @@ extension UserTimelineViewModel.State {
class Reloading: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
- case is Fail.Type:
- return true
- case is Idle.Type:
- return true
- case is NoMore.Type:
+ case is Loading.Type:
return true
default:
return false
@@ -57,69 +53,38 @@ extension UserTimelineViewModel.State {
// reset
viewModel.statusFetchedResultsController.statusIDs.value = []
-
- guard let userID = viewModel.userID.value, !userID.isEmpty else {
- stateMachine.enter(Fail.self)
- return
- }
-
- guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
- stateMachine.enter(Fail.self)
- return
- }
- let domain = activeMastodonAuthenticationBox.domain
- let queryFilter = viewModel.queryFilter.value
-
- viewModel.context.apiService.userTimeline(
- domain: domain,
- accountID: userID,
- maxID: nil,
- sinceID: nil,
- excludeReplies: queryFilter.excludeReplies,
- excludeReblogs: queryFilter.excludeReblogs,
- onlyMedia: queryFilter.onlyMedia,
- authorizationBox: activeMastodonAuthenticationBox
- )
- .receive(on: DispatchQueue.main)
- .sink { completion in
-
- } receiveValue: { response in
- os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
-
- var hasNewStatusesAppend = false
- var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
- for status in response.value {
- guard !statusIDs.contains(status.id) else { continue }
- statusIDs.append(status.id)
- hasNewStatusesAppend = true
- }
-
- if hasNewStatusesAppend {
- stateMachine.enter(Idle.self)
- } else {
- stateMachine.enter(NoMore.self)
- }
- viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
- }
- .store(in: &viewModel.disposeBag)
+
+ stateMachine.enter(Loading.self)
}
}
class Fail: UserTimelineViewModel.State {
+
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
- case is Reloading.Type, is LoadingMore.Type:
+ case is Loading.Type:
return true
default:
return false
}
}
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+ guard let _ = viewModel, let stateMachine = stateMachine else { return }
+
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
+ stateMachine.enter(Loading.self)
+ }
+ }
}
class Idle: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
- case is Reloading.Type, is LoadingMore.Type:
+ case is Reloading.Type, is Loading.Type:
return true
default:
return false
@@ -127,7 +92,7 @@ extension UserTimelineViewModel.State {
}
}
- class LoadingMore: UserTimelineViewModel.State {
+ class Loading: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
@@ -145,10 +110,7 @@ extension UserTimelineViewModel.State {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
- guard let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last else {
- stateMachine.enter(Fail.self)
- return
- }
+ let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last
guard let userID = viewModel.userID.value, !userID.isEmpty else {
stateMachine.enter(Fail.self)
@@ -177,6 +139,7 @@ extension UserTimelineViewModel.State {
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
+ stateMachine.enter(Fail.self)
case .finished:
break
}
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift
index 2276db5fe..03e5e627d 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift
@@ -12,9 +12,8 @@ import Combine
import CoreData
import CoreDataStack
import MastodonSDK
-import AlamofireImage
-class UserTimelineViewModel: NSObject {
+final class UserTimelineViewModel {
var disposeBag = Set()
@@ -28,6 +27,8 @@ class UserTimelineViewModel: NSObject {
let isBlocking = CurrentValueSubject(false)
let isBlockedBy = CurrentValueSubject(false)
+ let isSuspended = CurrentValueSubject(false)
+ let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label
// output
var diffableDataSource: UITableViewDiffableDataSource?
@@ -37,7 +38,7 @@ class UserTimelineViewModel: NSObject {
State.Reloading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
- State.LoadingMore(viewModel: self),
+ State.Loading(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
@@ -54,20 +55,21 @@ class UserTimelineViewModel: NSObject {
self.domain = CurrentValueSubject(domain)
self.userID = CurrentValueSubject(userID)
self.queryFilter = CurrentValueSubject(queryFilter)
- super.init()
+ // super.init()
self.domain
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
- Publishers.CombineLatest3(
+ Publishers.CombineLatest4(
statusFetchedResultsController.objectIDs.eraseToAnyPublisher(),
isBlocking.eraseToAnyPublisher(),
- isBlockedBy.eraseToAnyPublisher()
+ isBlockedBy.eraseToAnyPublisher(),
+ isSuspended.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
- .sink { [weak self] objectIDs, isBlocking, isBlockedBy in
+ .sink { [weak self] objectIDs, isBlocking, isBlockedBy, isSuspended in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
@@ -90,6 +92,12 @@ class UserTimelineViewModel: NSObject {
return
}
+ let name = self.userDisplayName.value
+ guard !isSuspended else {
+ snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .suspended(name: name)))], toSection: .main)
+ return
+ }
+
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
let oldSnapshot = diffableDataSource.snapshot()
for item in oldSnapshot.itemIdentifiers {
@@ -105,7 +113,7 @@ class UserTimelineViewModel: NSObject {
if let currentState = self.stateMachine.currentState {
switch currentState {
- case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail:
+ case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
case is State.NoMore:
break
@@ -114,8 +122,6 @@ class UserTimelineViewModel: NSObject {
break
}
}
-
-
}
.store(in: &disposeBag)
}
@@ -144,4 +150,3 @@ extension UserTimelineViewModel {
}
}
-
diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift
similarity index 72%
rename from Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift
rename to Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift
index 78d5a971c..b136859a8 100644
--- a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift
+++ b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift
@@ -1,5 +1,5 @@
//
-// HashtagTimelineTitleView.swift
+// DoubleTitleLabelNavigationBarTitleView.swift
// Mastodon
//
// Created by BradGao on 2021/4/1.
@@ -7,7 +7,7 @@
import UIKit
-final class HashtagTimelineNavigationBarTitleView: UIView {
+final class DoubleTitleLabelNavigationBarTitleView: UIView {
let containerView = UIStackView()
@@ -40,7 +40,7 @@ final class HashtagTimelineNavigationBarTitleView: UIView {
}
-extension HashtagTimelineNavigationBarTitleView {
+extension DoubleTitleLabelNavigationBarTitleView {
private func _init() {
containerView.axis = .vertical
containerView.alignment = .center
@@ -58,10 +58,10 @@ extension HashtagTimelineNavigationBarTitleView {
containerView.addArrangedSubview(subtitleLabel)
}
- func updateTitle(hashtag: String, peopleNumber: String?) {
- titleLabel.text = "#\(hashtag)"
- if let peopleNumebr = peopleNumber {
- subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr)
+ func update(title: String, subtitle: String?) {
+ titleLabel.text = title
+ if let subtitle = subtitle {
+ subtitleLabel.text = subtitle
subtitleLabel.isHidden = false
} else {
subtitleLabel.text = nil
@@ -69,3 +69,21 @@ extension HashtagTimelineNavigationBarTitleView {
}
}
}
+
+#if canImport(SwiftUI) && DEBUG
+
+import SwiftUI
+
+struct DoubleTitleLabelNavigationBarTitleView_Previews: PreviewProvider {
+
+ static var previews: some View {
+ UIViewPreview(width: 375) {
+ DoubleTitleLabelNavigationBarTitleView()
+ }
+ .previewLayout(.fixed(width: 375, height: 100))
+ }
+
+}
+
+#endif
+
diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift
index e253b3ca7..b5e4c5bde 100644
--- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift
+++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift
@@ -84,8 +84,10 @@ extension TimelineHeaderView {
extension Item.EmptyStateHeaderAttribute.Reason {
var iconImage: UIImage? {
switch self {
- case .noStatusFound, .blocking, .blocked, .suspended:
+ case .noStatusFound, .blocking, .blocked:
return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))!
+ case .suspended:
+ return UIImage(systemName: "person.crop.circle.badge.xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))!
}
}
@@ -97,8 +99,12 @@ extension Item.EmptyStateHeaderAttribute.Reason {
return L10n.Common.Controls.Timeline.Header.blockingWarning
case .blocked:
return L10n.Common.Controls.Timeline.Header.blockedWarning
- case .suspended:
- return L10n.Common.Controls.Timeline.Header.suspendedWarning
+ case .suspended(let name):
+ if let name = name {
+ return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name)
+ } else {
+ return L10n.Common.Controls.Timeline.Header.suspendedWarning
+ }
}
}
}
diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift
index d49a7371b..23206494e 100644
--- a/Mastodon/Service/APIService/APIService+Favorite.swift
+++ b/Mastodon/Service/APIService/APIService+Favorite.swift
@@ -16,7 +16,7 @@ import CommonOSLog
extension APIService {
// make local state change only
- func like(
+ func favorite(
statusObjectID: NSManagedObjectID,
mastodonUserObjectID: NSManagedObjectID,
favoriteKind: Mastodon.API.Favorites.FavoriteKind
@@ -50,7 +50,7 @@ extension APIService {
}
// send favorite request to remote
- func like(
+ func favorite(
statusID: Mastodon.Entity.Status.ID,
favoriteKind: Mastodon.API.Favorites.FavoriteKind,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
@@ -128,16 +128,20 @@ extension APIService {
}
extension APIService {
- func likeList(
+ func favoritedStatuses(
limit: Int = onceRequestStatusMaxCount,
- userID: String,
maxID: String? = nil,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher, Error> {
let requestMastodonUserID = mastodonAuthenticationBox.userID
- let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID)
- return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query)
+ let query = Mastodon.API.Favorites.FavoriteStatusesQuery(limit: limit, minID: nil, maxID: maxID)
+ return Mastodon.API.Favorites.favoritedStatus(
+ domain: mastodonAuthenticationBox.domain,
+ session: session,
+ authorization: mastodonAuthenticationBox.userAuthorization,
+ query: query
+ )
.map { response -> AnyPublisher, Error> in
let log = OSLog.api
diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift
index f2c57db57..cf19878d6 100644
--- a/Mastodon/Service/APIService/APIService+Follow.swift
+++ b/Mastodon/Service/APIService/APIService+Follow.swift
@@ -158,6 +158,7 @@ extension APIService {
) -> AnyPublisher, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization
+ let requestMastodonUserID = mastodonAuthenticationBox.userID
return Mastodon.API.Account.follow(
session: session,
@@ -166,22 +167,50 @@ extension APIService {
followQueryType: followQueryType,
authorization: authorization
)
- .handleEvents(receiveCompletion: { [weak self] completion in
- guard let _ = self else { return }
- switch completion {
- case .failure(let error):
- // TODO: handle error
- os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
- break
- case .finished:
- switch followQueryType {
- case .follow:
- break
- case .unfollow:
- break
+// .handleEvents(receiveCompletion: { [weak self] completion in
+// guard let _ = self else { return }
+// switch completion {
+// case .failure(let error):
+// // TODO: handle error
+// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
+// break
+// case .finished:
+// switch followQueryType {
+// case .follow:
+// break
+// case .unfollow:
+// break
+// }
+// }
+// })
+ .flatMap { response -> AnyPublisher, Error> in
+ let managedObjectContext = self.backgroundManagedObjectContext
+ return managedObjectContext.performChanges {
+ let requestMastodonUserRequest = MastodonUser.sortedFetchRequest
+ requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
+ requestMastodonUserRequest.fetchLimit = 1
+ guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return }
+
+ let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest
+ lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID)
+ lookUpMastodonUserRequest.fetchLimit = 1
+ let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first
+
+ if let lookUpMastodonuser = lookUpMastodonuser {
+ let entity = response.value
+ APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate)
}
}
- })
+ .tryMap { result -> Mastodon.Response.Content in
+ switch result {
+ case .success:
+ return response
+ case .failure(let error):
+ throw error
+ }
+ }
+ .eraseToAnyPublisher()
+ }
.eraseToAnyPublisher()
}
diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift
index fdac2a2a6..4a1237051 100644
--- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift
+++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift
@@ -95,6 +95,9 @@ extension APIService.CoreData {
user.update(statusesCount: property.statusesCount)
user.update(followingCount: property.followingCount)
user.update(followersCount: property.followersCount)
+ user.update(locked: property.locked)
+ property.bot.flatMap { user.update(bot: $0) }
+ property.suspended.flatMap { user.update(suspended: $0) }
user.didUpdate(at: networkDate)
}
diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift
index dfd41094a..9bc699b71 100644
--- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift
+++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift
@@ -234,8 +234,8 @@ extension APIService.Persist {
let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in
return next.statusProcessType == .create ? result + 1 : result
})
- os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge)
- os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
+ os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: status: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge)
+ os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
}
#endif
}
diff --git a/Mastodon/ViewController.swift b/Mastodon/ViewController.swift
deleted file mode 100644
index 9be4589cc..000000000
--- a/Mastodon/ViewController.swift
+++ /dev/null
@@ -1,15 +0,0 @@
-//
-// ViewController.swift
-// Mastodon
-//
-// Created by MainasuK Cirno on 2021-3-31.
-//
-
-import UIKit
-
-class ViewController: UIViewController {
-
- override var preferredStatusBarStyle: UIStatusBarStyle {
- return .darkContent
- }
-}
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift
index 64598bc14..01b9d61a4 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift
@@ -14,78 +14,6 @@ extension Mastodon.API.Favorites {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites")
}
- static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL {
- let pathComponent = "statuses/" + statusID + "/favourited_by"
- return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
- }
-
- static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL {
- var actionString: String
- switch favoriteKind {
- case .create:
- actionString = "/favourite"
- case .destroy:
- actionString = "/unfavourite"
- }
- let pathComponent = "statuses/" + statusID + actionString
- return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
- }
-
- /// Favourite / Undo Favourite
- ///
- /// Add a status to your favourites list / Remove a status from your favourites list
- ///
- /// - Since: 0.0.0
- /// - Version: 3.3.0
- /// # Last Update
- /// 2021/3/3
- /// # Reference
- /// [Document](https://docs.joinmastodon.org/methods/statuses/)
- /// - Parameters:
- /// - domain: Mastodon instance domain. e.g. "example.com"
- /// - statusID: Mastodon status id
- /// - session: `URLSession`
- /// - authorization: User token
- /// - Returns: `AnyPublisher` contains `Server` nested in the response
- public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher, Error> {
- let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind)
- var request = Mastodon.API.post(url: url, query: nil, authorization: authorization)
- request.httpMethod = "POST"
- return session.dataTaskPublisher(for: request)
- .tryMap { data, response in
- let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response)
- return Mastodon.Response.Content(value: value, response: response)
- }
- .eraseToAnyPublisher()
- }
-
- /// Favourited by
- ///
- /// View who favourited a given status.
- ///
- /// - Since: 0.0.0
- /// - Version: 3.3.0
- /// # Last Update
- /// 2021/3/3
- /// # Reference
- /// [Document](https://docs.joinmastodon.org/methods/statuses/)
- /// - Parameters:
- /// - domain: Mastodon instance domain. e.g. "example.com"
- /// - statusID: Mastodon status id
- /// - session: `URLSession`
- /// - authorization: User token
- /// - Returns: `AnyPublisher` contains `Server` nested in the response
- public static func favoriteBy(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> {
- let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID)
- let request = Mastodon.API.get(url: url, query: nil, authorization: authorization)
- return session.dataTaskPublisher(for: request)
- .tryMap { data, response in
- let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
- return Mastodon.Response.Content(value: value, response: response)
- }
- .eraseToAnyPublisher()
- }
-
/// Favourited statuses
///
/// Using this endpoint to view the favourited list for user
@@ -101,7 +29,12 @@ extension Mastodon.API.Favorites {
/// - session: `URLSession`
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Server` nested in the response
- public static func favoritedStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> {
+ public static func favoritedStatus(
+ domain: String,
+ session: URLSession,
+ authorization: Mastodon.API.OAuth.Authorization,
+ query: Mastodon.API.Favorites.FavoriteStatusesQuery
+ ) -> AnyPublisher, Error> {
let url = favoritesStatusesEndpointURL(domain: domain)
let request = Mastodon.API.get(url: url, query: query, authorization: authorization)
return session.dataTaskPublisher(for: request)
@@ -112,16 +45,7 @@ extension Mastodon.API.Favorites {
.eraseToAnyPublisher()
}
-}
-
-extension Mastodon.API.Favorites {
-
- public enum FavoriteKind {
- case create
- case destroy
- }
-
- public struct ListQuery: GetQuery, PagedQueryType {
+ public struct FavoriteStatusesQuery: GetQuery, PagedQueryType {
public var limit: Int?
public var minID: String?
@@ -155,3 +79,99 @@ extension Mastodon.API.Favorites {
}
}
+
+extension Mastodon.API.Favorites {
+
+ static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL {
+ var actionString: String
+ switch favoriteKind {
+ case .create:
+ actionString = "/favourite"
+ case .destroy:
+ actionString = "/unfavourite"
+ }
+ let pathComponent = "statuses/" + statusID + actionString
+ return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
+ }
+
+ /// Favourite / Undo Favourite
+ ///
+ /// Add a status to your favourites list / Remove a status from your favourites list
+ ///
+ /// - Since: 0.0.0
+ /// - Version: 3.3.0
+ /// # Last Update
+ /// 2021/3/3
+ /// # Reference
+ /// [Document](https://docs.joinmastodon.org/methods/statuses/)
+ /// - Parameters:
+ /// - domain: Mastodon instance domain. e.g. "example.com"
+ /// - statusID: Mastodon status id
+ /// - session: `URLSession`
+ /// - authorization: User token
+ /// - Returns: `AnyPublisher` contains `Server` nested in the response
+ public static func favorites(
+ domain: String,
+ statusID: String,
+ session: URLSession,
+ authorization: Mastodon.API.OAuth.Authorization,
+ favoriteKind: FavoriteKind
+ ) -> AnyPublisher, Error> {
+ let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind)
+ var request = Mastodon.API.post(url: url, query: nil, authorization: authorization)
+ request.httpMethod = "POST"
+ return session.dataTaskPublisher(for: request)
+ .tryMap { data, response in
+ let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response)
+ return Mastodon.Response.Content(value: value, response: response)
+ }
+ .eraseToAnyPublisher()
+ }
+
+ public enum FavoriteKind {
+ case create
+ case destroy
+ }
+
+}
+
+extension Mastodon.API.Favorites {
+
+ static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL {
+ let pathComponent = "statuses/" + statusID + "/favourited_by"
+ return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
+ }
+
+ /// Favourited by
+ ///
+ /// View who favourited a given status.
+ ///
+ /// - Since: 0.0.0
+ /// - Version: 3.3.0
+ /// # Last Update
+ /// 2021/3/3
+ /// # Reference
+ /// [Document](https://docs.joinmastodon.org/methods/statuses/)
+ /// - Parameters:
+ /// - domain: Mastodon instance domain. e.g. "example.com"
+ /// - statusID: Mastodon status id
+ /// - session: `URLSession`
+ /// - authorization: User token
+ /// - Returns: `AnyPublisher` contains `Server` nested in the response
+ public static func favoriteBy(
+ domain: String,
+ statusID: String,
+ session: URLSession,
+ authorization: Mastodon.API.OAuth.Authorization
+ ) -> AnyPublisher, Error> {
+ let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID)
+ let request = Mastodon.API.get(url: url, query: nil, authorization: authorization)
+ return session.dataTaskPublisher(for: request)
+ .tryMap { data, response in
+ let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
+ return Mastodon.Response.Content(value: value, response: response)
+ }
+ .eraseToAnyPublisher()
+ }
+
+}
diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift
index a74d0fcaa..9c39615f9 100644
--- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift
+++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift
@@ -18,6 +18,7 @@ extension Mastodon.Response {
// application fields
public let rateLimit: RateLimit?
+ public let link: Link?
public let responseTime: Int?
public var networkDate: Date {
@@ -33,6 +34,11 @@ extension Mastodon.Response {
}()
self.rateLimit = RateLimit(response: response)
+ self.link = {
+ guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "link") else { return nil }
+ return Link(link: string)
+ }()
+
self.responseTime = {
guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil }
return Int(string)
@@ -43,6 +49,7 @@ extension Mastodon.Response {
self.value = value
self.date = old.date
self.rateLimit = old.rateLimit
+ self.link = old.link
self.responseTime = old.responseTime
}
@@ -90,3 +97,30 @@ extension Mastodon.Response {
}
}
+
+extension Mastodon.Response {
+ public struct Link {
+ public let maxID: Mastodon.Entity.Status.ID?
+ public let minID: Mastodon.Entity.Status.ID?
+
+ init(link: String) {
+ self.maxID = {
+ guard let regex = try? NSRegularExpression(pattern: "max_id=([[:digit:]]+)", options: []) else { return nil }
+ let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex..