forked from zelo72/mastodon-ios
Merge pull request #99 from tootsuite/feature/favorite-and-profile-edit
Add profile edit support and favorites scene
This commit is contained in:
commit
152e6d7aad
|
@ -82,6 +82,7 @@
|
|||
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="note" optional="YES" attributeType="String"/>
|
||||
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="suspended" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="url" optional="YES" attributeType="String"/>
|
||||
<attribute name="username" attributeType="String"/>
|
||||
|
@ -199,7 +200,7 @@
|
|||
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
|
||||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="659"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="674"/>
|
||||
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
|
||||
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
|
||||
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
@ -303,6 +309,9 @@
|
|||
},
|
||||
"hashtag": {
|
||||
"prompt": "%s people talking"
|
||||
},
|
||||
"favorite": {
|
||||
"title": "Your Favorites"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = "<group>"; };
|
||||
0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTitleLabelNavigationBarTitleView.swift; sourceTree = "<group>"; };
|
||||
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = "<group>"; };
|
||||
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = "<group>"; };
|
||||
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
|
@ -589,10 +596,12 @@
|
|||
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = "<group>"; };
|
||||
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
|
||||
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
|
||||
DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = "<group>"; };
|
||||
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
|
||||
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
|
||||
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = "<group>"; };
|
||||
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = "<group>"; };
|
||||
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
|
||||
|
@ -673,7 +682,6 @@
|
|||
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = "<group>"; };
|
||||
DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = "<group>"; };
|
||||
DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = "<group>"; };
|
||||
DBCC3B7A261443AD0045B23D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
|
||||
DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = "<group>"; };
|
||||
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = "<group>"; };
|
||||
|
@ -684,6 +692,12 @@
|
|||
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
|
||||
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = "<group>"; };
|
||||
DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewController.swift; sourceTree = "<group>"; };
|
||||
DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = "<group>"; };
|
||||
DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
||||
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = "<group>"; };
|
||||
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = "<group>"; };
|
||||
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
|
@ -745,23 +759,14 @@
|
|||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0F1E2D102615C39800C38565 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
|
@ -971,6 +977,7 @@
|
|||
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
|
||||
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */,
|
||||
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */,
|
||||
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */,
|
||||
);
|
||||
path = Protocol;
|
||||
sourceTree = "<group>";
|
||||
|
@ -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 = "<group>";
|
||||
};
|
||||
DB73B495261F030D002E9E9F /* Activity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB73B48F261F030A002E9E9F /* SafariActivity.swift */,
|
||||
);
|
||||
path = Activity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
|
@ -1739,6 +1756,18 @@
|
|||
path = Register;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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;
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>12</integer>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -16,7 +16,8 @@ extension CategoryPickerSection {
|
|||
for collectionView: UICollectionView,
|
||||
dependency: NeedsDependency
|
||||
) -> UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem> {
|
||||
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:
|
||||
|
|
|
@ -17,7 +17,8 @@ extension CustomEmojiPickerSection {
|
|||
for collectionView: UICollectionView,
|
||||
dependency: NeedsDependency
|
||||
) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> {
|
||||
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
|
||||
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(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
|
||||
|
|
|
@ -25,7 +25,13 @@ extension PickServerSection {
|
|||
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
|
||||
pickServerCellDelegate: PickServerCellDelegate
|
||||
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
|
||||
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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
@ -51,10 +51,19 @@ extension AvatarConfigurableView {
|
|||
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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,30 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
protocol TableViewCellHeightCacheableContainer: UIViewController {
|
||||
// TODO:
|
||||
protocol TableViewCellHeightCacheableContainer: StatusProvider {
|
||||
var cellFrameCache: NSCache<NSNumber, NSValue> { 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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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<NSNumber, NSValue> {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -67,6 +67,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([
|
||||
nextStepButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: MastodonPickServerViewController.actionButtonMargin),
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
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,
|
||||
|
|
|
@ -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<Status?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never> {
|
||||
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<Status?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
var managedObjectContext: NSManagedObjectContext {
|
||||
return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
}
|
||||
|
||||
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
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<NSNumber, NSValue> {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<StatusSection, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
|
||||
stateMachine.enter(State.Reloading.self)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let activeMastodonAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||
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<StatusSection, Item>()
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
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"])
|
||||
|
@ -34,6 +52,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 {
|
||||
|
@ -126,19 +304,132 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let isEditing = CurrentValueSubject<Bool, Never>(false)
|
||||
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
|
||||
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
|
||||
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(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<String?, Never>(nil)
|
||||
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
|
||||
let note = CurrentValueSubject<String?, Never>(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<Mastodon.Response.Content<Mastodon.Entity.Account>, 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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}()
|
||||
|
||||
|
@ -54,15 +60,39 @@ final class ProfileHeaderView: UIView {
|
|||
return imageView
|
||||
}()
|
||||
|
||||
let nameLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
label.minimumScaleFactor = 0.5
|
||||
label.textColor = .white
|
||||
label.text = "Alice"
|
||||
label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
|
||||
return label
|
||||
let 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 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)
|
||||
|
@ -138,11 +188,31 @@ extension ProfileHeaderView {
|
|||
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
|
||||
])
|
||||
|
||||
// name container: [display name | username]
|
||||
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 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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 ?? "<nil>")
|
||||
os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "<nil>")
|
||||
|
||||
guard let self = self else { return }
|
||||
self.mastodonUser.value = currentMastodonUser
|
||||
|
|
|
@ -18,6 +18,30 @@ final class ProfileViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
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
|
||||
|
||||
Publishers.CombineLatest(
|
||||
let editingAndUpdatingPublisher = Publishers.CombineLatest(
|
||||
viewModel.isEditing.eraseToAnyPublisher(),
|
||||
viewModel.isUpdating.eraseToAnyPublisher()
|
||||
)
|
||||
// note: not add .share() here
|
||||
|
||||
let barButtonItemHiddenPublisher = Publishers.CombineLatest3(
|
||||
viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(),
|
||||
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
|
||||
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
|
||||
)
|
||||
|
||||
editingAndUpdatingPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
|
||||
.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] 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()
|
||||
)
|
||||
viewModel.avatarImageURL
|
||||
.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)
|
||||
)
|
||||
}
|
||||
.map { url in ProfileHeaderViewModel.ProfileInfo.ImageResource.url(url) }
|
||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.avatarImageResource)
|
||||
.store(in: &disposeBag)
|
||||
viewModel.name
|
||||
.map { $0 ?? " " }
|
||||
.map { $0 ?? "" }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel)
|
||||
.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 }
|
||||
|
@ -415,11 +564,6 @@ extension ProfileViewController {
|
|||
}
|
||||
}
|
||||
|
||||
// @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))
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
|
@ -436,12 +580,17 @@ extension ProfileViewController: UIScrollViewDelegate {
|
|||
contentOffsets.removeAll()
|
||||
} else {
|
||||
containerScrollView.contentOffset.y = topMaxContentOffsetY
|
||||
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
|
||||
let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY
|
||||
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress)
|
||||
|
@ -492,22 +641,42 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
|
|||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
|
||||
let relationshipActionSet = viewModel.relationshipActionOptionSet.value
|
||||
if relationshipActionSet.contains(.edit) {
|
||||
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) {
|
||||
|
|
|
@ -41,10 +41,12 @@ class ProfileViewModel: NSObject {
|
|||
let followersCount: CurrentValueSubject<Int?, Never>
|
||||
|
||||
let protected: CurrentValueSubject<Bool?, Never>
|
||||
// let suspended: CurrentValueSubject<Bool, Never>
|
||||
let suspended: CurrentValueSubject<Bool, Never>
|
||||
|
||||
let isEditing = CurrentValueSubject<Bool, Never>(false)
|
||||
let isUpdating = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
|
||||
let isEditing = CurrentValueSubject<Bool, Never>(false)
|
||||
let isFollowedBy = CurrentValueSubject<Bool, Never>(false)
|
||||
let isMuting = CurrentValueSubject<Bool, Never>(false)
|
||||
let isBlocking = CurrentValueSubject<Bool, Never>(false)
|
||||
|
@ -53,6 +55,9 @@ class ProfileViewModel: NSObject {
|
|||
let isRelationshipActionButtonHidden = CurrentValueSubject<Bool, Never>(true)
|
||||
let isReplyBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
|
||||
let isMoreMenuBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
|
||||
let isMeBarButtonItemsHidden = CurrentValueSubject<Bool, Never>(true)
|
||||
|
||||
let needsPagePinToTop = CurrentValueSubject<Bool, Never>(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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<NSNumber, NSValue> {
|
||||
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
|
||||
aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
// os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
|
||||
|
||||
return ceil(frame.height)
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
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 }
|
||||
|
|
|
@ -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
|
||||
|
@ -58,68 +54,37 @@ 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
|
||||
}
|
||||
|
|
|
@ -12,9 +12,8 @@ import Combine
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
|
||||
class UserTimelineViewModel: NSObject {
|
||||
final class UserTimelineViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
|
@ -28,6 +27,8 @@ class UserTimelineViewModel: NSObject {
|
|||
|
||||
let isBlocking = CurrentValueSubject<Bool, Never>(false)
|
||||
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
|
||||
let isSuspended = CurrentValueSubject<Bool, Never>(false)
|
||||
let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||
|
@ -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 {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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,10 +99,14 @@ extension Item.EmptyStateHeaderAttribute.Reason {
|
|||
return L10n.Common.Controls.Timeline.Header.blockingWarning
|
||||
case .blocked:
|
||||
return L10n.Common.Controls.Timeline.Header.blockedWarning
|
||||
case .suspended:
|
||||
case .suspended(let name):
|
||||
if let name = name {
|
||||
return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name)
|
||||
} else {
|
||||
return L10n.Common.Controls.Timeline.Header.suspendedWarning
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG && canImport(SwiftUI)
|
||||
|
|
|
@ -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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||
let log = OSLog.api
|
||||
|
||||
|
|
|
@ -158,6 +158,7 @@ extension APIService {
|
|||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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 {
|
||||
// .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<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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<Mastodon.Entity.Relationship> in
|
||||
switch result {
|
||||
case .success:
|
||||
return response
|
||||
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
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<[Mastodon.Entity.Account]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||
public static func favoritedStatus(
|
||||
domain: String,
|
||||
session: URLSession,
|
||||
authorization: Mastodon.API.OAuth.Authorization,
|
||||
query: Mastodon.API.Favorites.FavoriteStatusesQuery
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<[Mastodon.Entity.Account]>, 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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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..<link.endIndex, in: link))
|
||||
guard let match = results.first else { return nil }
|
||||
guard let range = Range(match.range(at: 1), in: link) else { return nil }
|
||||
let id = link[range]
|
||||
return String(id)
|
||||
}()
|
||||
|
||||
self.minID = {
|
||||
guard let regex = try? NSRegularExpression(pattern: "min_id=([[:digit:]]+)", options: []) else { return nil }
|
||||
let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex..<link.endIndex, in: link))
|
||||
guard let match = results.first else { return nil }
|
||||
guard let range = Range(match.range(at: 1), in: link) else { return nil }
|
||||
let id = link[range]
|
||||
return String(id)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,6 +54,7 @@ arch -x86_64 pod install
|
|||
- [SwiftGen](https://github.com/SwiftGen/SwiftGen)
|
||||
- [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)
|
||||
- [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor)
|
||||
- [TwitterProfile](https://github.com/OfTheWolf/TwitterProfile)
|
||||
- [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder)
|
||||
|
||||
## License
|
||||
|
|
Loading…
Reference in New Issue