Merge pull request #99 from tootsuite/feature/favorite-and-profile-edit

Add profile edit support and favorites scene
This commit is contained in:
CMK 2021-04-13 09:20:33 +08:00 committed by GitHub
commit 152e6d7aad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2302 additions and 403 deletions

View File

@ -82,6 +82,7 @@
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="note" optional="YES" attributeType="String"/> <attribute name="note" optional="YES" attributeType="String"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <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="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/> <attribute name="url" optional="YES" attributeType="String"/>
<attribute name="username" attributeType="String"/> <attribute name="username" attributeType="String"/>
@ -199,7 +200,7 @@
<element name="History" positionX="27" positionY="126" width="128" height="119"/> <element name="History" positionX="27" positionY="126" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/> <element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/> <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="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/> <element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/> <element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>

View File

@ -31,6 +31,7 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var locked: Bool @NSManaged public private(set) var locked: Bool
@NSManaged public private(set) var bot: 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 createdAt: Date
@NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var updatedAt: Date
@ -93,6 +94,7 @@ extension MastodonUser {
user.locked = property.locked user.locked = property.locked
user.bot = property.bot ?? false user.bot = property.bot ?? false
user.suspended = property.suspended ?? false
// Mastodon do not provide relationship on the `Account` // Mastodon do not provide relationship on the `Account`
// Update relationship via attribute updating interface // Update relationship via attribute updating interface
@ -174,6 +176,11 @@ extension MastodonUser {
self.bot = bot self.bot = bot
} }
} }
public func update(suspended: Bool) {
if self.suspended != suspended {
self.suspended = suspended
}
}
public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { public func update(isFollowing: Bool, by mastodonUser: MastodonUser) {
if isFollowing { if isFollowing {
@ -268,6 +275,7 @@ extension MastodonUser {
public let followersCount: Int public let followersCount: Int
public let locked: Bool public let locked: Bool
public let bot: Bool? public let bot: Bool?
public let suspended: Bool?
public let createdAt: Date public let createdAt: Date
public let networkDate: Date public let networkDate: Date
@ -289,6 +297,7 @@ extension MastodonUser {
followersCount: Int, followersCount: Int,
locked: Bool, locked: Bool,
bot: Bool?, bot: Bool?,
suspended: Bool?,
createdAt: Date, createdAt: Date,
networkDate: Date networkDate: Date
) { ) {
@ -309,6 +318,7 @@ extension MastodonUser {
self.followersCount = followersCount self.followersCount = followersCount
self.locked = locked self.locked = locked
self.bot = bot self.bot = bot
self.suspended = suspended
self.createdAt = createdAt self.createdAt = createdAt
self.networkDate = networkDate self.networkDate = networkDate
} }

View File

@ -44,6 +44,8 @@
"sign_up": "Sign Up", "sign_up": "Sign Up",
"see_more": "See More", "see_more": "See More",
"preview": "Preview", "preview": "Preview",
"share": "Share",
"share_user": "Share %s",
"open_in_safari": "Open in Safari" "open_in_safari": "Open in Safari"
}, },
"status": { "status": {
@ -69,9 +71,11 @@
"firendship": { "firendship": {
"follow": "Follow", "follow": "Follow",
"following": "Following", "following": "Following",
"request": "Request",
"pending": "Pending", "pending": "Pending",
"block": "Block", "block": "Block",
"block_user": "Block %s", "block_user": "Block %s",
"block_domain": "Block %s",
"unblock": "Unblock", "unblock": "Unblock",
"unblock_user": "Unblock %s", "unblock_user": "Unblock %s",
"blocked": "Blocked", "blocked": "Blocked",
@ -91,7 +95,8 @@
"no_status_found": "No Status Found", "no_status_found": "No Status Found",
"blocking_warning": "You cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.", "blocking_warning": "You cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.",
"blocked_warning": "You cant view Artbots profile\n until they unblock you.", "blocked_warning": "You cant view Artbots 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", "new_posts": "See new posts",
"published": "Published!", "published": "Published!",
"Publishing": "Publishing post..." "Publishing": "Publishing post..."
}, }
}, },
"public_timeline": { "public_timeline": {
"title": "Public" "title": "Public"
@ -261,6 +266,7 @@
} }
}, },
"profile": { "profile": {
"subtitle": "%s posts",
"dashboard": { "dashboard": {
"posts": "posts", "posts": "posts",
"following": "following", "following": "following",
@ -288,21 +294,24 @@
"cancel": "Cancel" "cancel": "Cancel"
}, },
"recommend": { "recommend": {
"buttonText": "See All", "buttonText": "See All",
"hash_tag": { "hash_tag": {
"title": "Trending in your timeline", "title": "Trending in your timeline",
"description": "Hashtags that are getting quite a bit of attention among people you follow", "description": "Hashtags that are getting quite a bit of attention among people you follow",
"people_talking": "%s people are talking" "people_talking": "%s people are talking"
}, },
"accounts": { "accounts": {
"title": "Accounts you might like", "title": "Accounts you might like",
"description": "Except for Sam, you will not like his account.", "description": "Except for Sam, you will not like his account.",
"follow": "Follow" "follow": "Follow"
} }
} }
}, },
"hashtag": { "hashtag": {
"prompt": "%s people talking" "prompt": "%s people talking"
},
"favorite": {
"title": "Your Favorites"
} }
} }
} }

View File

@ -7,7 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; };
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.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 */; }; 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 */; }; DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; };
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.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 */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; };
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; };
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; };
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.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 */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; };
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; };
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.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 */; }; DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; };
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; }; DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; };
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.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 */; }; DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; };
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.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 */; }; 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 */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; }; DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; };
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.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 */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; };
DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -371,7 +378,7 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
0F1E2D102615C39800C38565 /* View */ = {
isa = PBXGroup;
children = (
0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */,
);
path = View;
sourceTree = "<group>";
};
0F2021F5261325ED000C64BF /* HashtagTimeline */ = { 0F2021F5261325ED000C64BF /* HashtagTimeline */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0F1E2D102615C39800C38565 /* View */,
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */,
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */,
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */,
0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */, 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */,
); );
@ -841,6 +846,7 @@
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */,
DB87D44A2609C11900D12C0D /* PollOptionView.swift */, DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */,
0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */,
); );
path = Content; path = Content;
sourceTree = "<group>"; sourceTree = "<group>";
@ -971,6 +977,7 @@
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */,
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */,
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */,
); );
path = Protocol; path = Protocol;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1137,8 +1144,8 @@
DB084B5125CBC56300F898ED /* CoreDataStack */ = { DB084B5125CBC56300F898ED /* CoreDataStack */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
DB084B5625CBC56C00F898ED /* Status.swift */, DB084B5625CBC56C00F898ED /* Status.swift */,
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
DB9D6C3725E508BE0051B173 /* Attachment.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */,
); );
path = CoreDataStack; path = CoreDataStack;
@ -1219,7 +1226,6 @@
children = ( children = (
DB427DE325BAA00100D1B89D /* Info.plist */, DB427DE325BAA00100D1B89D /* Info.plist */,
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
DBCC3B7A261443AD0045B23D /* ViewController.swift */,
2D76319C25C151DE00929FB9 /* Diffiable */, 2D76319C25C151DE00929FB9 /* Diffiable */,
DB8AF52A25C13561002E6C99 /* State */, DB8AF52A25C13561002E6C99 /* State */,
2D61335525C1886800CAE157 /* Service */, 2D61335525C1886800CAE157 /* Service */,
@ -1228,6 +1234,7 @@
DB9E0D6925EDFFE500CFDD76 /* Helper */, DB9E0D6925EDFFE500CFDD76 /* Helper */,
DB8AF56225C138BC002E6C99 /* Extension */, DB8AF56225C138BC002E6C99 /* Extension */,
2D5A3D0125CF8640002347D6 /* Vender */, 2D5A3D0125CF8640002347D6 /* Vender */,
DB73B495261F030D002E9E9F /* Activity */,
DB5086CB25CC0DB400C2C187 /* Preference */, DB5086CB25CC0DB400C2C187 /* Preference */,
2D69CFF225CA9E2200C3A1B2 /* Protocol */, 2D69CFF225CA9E2200C3A1B2 /* Protocol */,
DB98338425C945ED00AD9700 /* Generated */, DB98338425C945ED00AD9700 /* Generated */,
@ -1367,6 +1374,14 @@
path = ServerRules; path = ServerRules;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
DB73B495261F030D002E9E9F /* Activity */ = {
isa = PBXGroup;
children = (
DB73B48F261F030A002E9E9F /* SafariActivity.swift */,
);
path = Activity;
sourceTree = "<group>";
};
DB789A1025F9F29B0071ACA0 /* Compose */ = { DB789A1025F9F29B0071ACA0 /* Compose */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1497,13 +1512,13 @@
DB8AF55525C1379F002E6C99 /* Scene */ = { DB8AF55525C1379F002E6C99 /* Scene */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0F2021F5261325ED000C64BF /* HashtagTimeline */,
5D03938E2612D200007FE196 /* Webview */, 5D03938E2612D200007FE196 /* Webview */,
2D7631A425C1532200929FB9 /* Share */, 2D7631A425C1532200929FB9 /* Share */,
DB8AF54E25C13703002E6C99 /* MainTab */, DB8AF54E25C13703002E6C99 /* MainTab */,
DB01409B25C40BB600F9F3CF /* Onboarding */, DB01409B25C40BB600F9F3CF /* Onboarding */,
2D38F1D325CD463600561493 /* HomeTimeline */, 2D38F1D325CD463600561493 /* HomeTimeline */,
2D76316325C14BAC00929FB9 /* PublicTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */,
0F2021F5261325ED000C64BF /* HashtagTimeline */,
DB9D6BEE25E4F5370051B173 /* Search */, DB9D6BEE25E4F5370051B173 /* Search */,
DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6BFD25E4F57B0051B173 /* Notification */,
DB9D6C0825E4F5A60051B173 /* Profile */, DB9D6C0825E4F5A60051B173 /* Profile */,
@ -1602,6 +1617,7 @@
DBB525132611EBB1002F1F29 /* Segmented */, DBB525132611EBB1002F1F29 /* Segmented */,
DBB525462611ED57002F1F29 /* Header */, DBB525462611ED57002F1F29 /* Header */,
DBB5253B2611ECF5002F1F29 /* Timeline */, DBB5253B2611ECF5002F1F29 /* Timeline */,
DBE3CDF1261C6B3100430CC6 /* Favorite */,
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */,
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
@ -1705,6 +1721,7 @@
children = ( children = (
DBB525732612D5A5002F1F29 /* View */, DBB525732612D5A5002F1F29 /* View */,
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */,
); );
path = Header; path = Header;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1739,6 +1756,18 @@
path = Register; path = Register;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */ /* Begin PBXHeadersBuildPhase section */
@ -2137,7 +2166,7 @@
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */, 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
@ -2158,6 +2187,7 @@
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
@ -2172,6 +2202,7 @@
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
@ -2181,21 +2212,25 @@
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */,
DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */,
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
@ -2213,12 +2248,12 @@
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */,
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */,
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */,
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
@ -2353,6 +2388,7 @@
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */,
DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@ -7,7 +7,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key> <key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>12</integer> <integer>10</integer>
</dict> </dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key> <key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict> <dict>

View File

@ -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)
}
}

View File

@ -33,8 +33,8 @@ extension SceneCoordinator {
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate) case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
case customPush case customPush
case safariPresent(animated: Bool, completion: (() -> Void)? = nil) case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
case alertController(animated: Bool, completion: (() -> Void)? = nil) case alertController(animated: Bool, completion: (() -> Void)? = nil)
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
} }
enum Scene { enum Scene {
@ -56,10 +56,12 @@ extension SceneCoordinator {
// profile // profile
case profile(viewModel: ProfileViewModel) case profile(viewModel: ProfileViewModel)
case favorite(viewModel: FavoriteViewModel)
// misc // misc
case alertController(alertController: UIAlertController)
case safari(url: URL) case safari(url: URL)
case alertController(alertController: UIAlertController)
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
#if DEBUG #if DEBUG
case publicTimeline case publicTimeline
@ -169,11 +171,11 @@ extension SceneCoordinator {
viewController.modalPresentationCapturesStatusBarAppearance = true viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion) presentingViewController.present(viewController, animated: animated, completion: completion)
case .activityViewControllerPresent(let animated, let completion): case .alertController(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion) presentingViewController.present(viewController, animated: animated, completion: completion)
case .alertController(let animated, let completion): case .activityViewControllerPresent(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion) presentingViewController.present(viewController, animated: animated, completion: completion)
} }
@ -232,6 +234,16 @@ private extension SceneCoordinator {
let _viewController = ProfileViewController() let _viewController = ProfileViewController()
_viewController.viewModel = viewModel _viewController.viewModel = viewModel
viewController = _viewController 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): case .alertController(let alertController):
if let popoverPresentationController = alertController.popoverPresentationController { if let popoverPresentationController = alertController.popoverPresentationController {
assert( assert(
@ -241,12 +253,10 @@ private extension SceneCoordinator {
) )
} }
viewController = alertController viewController = alertController
case .safari(let url): case .activityViewController(let activityViewController, let sourceView, let barButtonItem):
guard let scheme = url.scheme?.lowercased(), activityViewController.popoverPresentationController?.sourceView = sourceView
scheme == "http" || scheme == "https" else { activityViewController.popoverPresentationController?.barButtonItem = barButtonItem
return nil viewController = activityViewController
}
viewController = SFSafariViewController(url: url)
#if DEBUG #if DEBUG
case .publicTimeline: case .publicTimeline:
let _viewController = PublicTimelineViewController() let _viewController = PublicTimelineViewController()

View File

@ -63,11 +63,21 @@ extension Item {
let id = UUID() let id = UUID()
let reason: Reason let reason: Reason
enum Reason { enum Reason: Equatable {
case noStatusFound case noStatusFound
case blocking case blocking
case blocked 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) { init(reason: Reason) {

View File

@ -16,7 +16,8 @@ extension CategoryPickerSection {
for collectionView: UICollectionView, for collectionView: UICollectionView,
dependency: NeedsDependency dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem> { ) -> 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 let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
switch item { switch item {
case .all: case .all:

View File

@ -17,7 +17,8 @@ extension CustomEmojiPickerSection {
for collectionView: UICollectionView, for collectionView: UICollectionView,
dependency: NeedsDependency dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> { ) -> 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 { switch item {
case .emoji(let attribute): case .emoji(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell

View File

@ -25,7 +25,13 @@ extension PickServerSection {
pickServerSearchCellDelegate: PickServerSearchCellDelegate, pickServerSearchCellDelegate: PickServerSearchCellDelegate,
pickServerCellDelegate: PickServerCellDelegate pickServerCellDelegate: PickServerCellDelegate
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> { ) -> 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 { switch item {
case .header: case .header:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell

View File

@ -28,6 +28,7 @@ extension MastodonUser.Property {
followersCount: entity.followersCount, followersCount: entity.followersCount,
locked: entity.locked, locked: entity.locked,
bot: entity.bot, bot: entity.bot,
suspended: entity.suspended,
createdAt: entity.createdAt, createdAt: entity.createdAt,
networkDate: networkDate 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
}
}

View File

@ -86,6 +86,8 @@ internal enum Asset {
} }
internal enum Profile { internal enum Profile {
internal enum Banner { internal enum Banner {
internal static let bioEditBackgroundGray = ColorAsset(name: "Profile/Banner/bio.edit.background.gray")
internal static let nameEditBackgroundGray = ColorAsset(name: "Profile/Banner/name.edit.background.gray")
internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray") internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray")
} }
} }

View File

@ -78,6 +78,12 @@ internal enum L10n {
internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto") internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto")
/// See More /// See More
internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore") 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 /// Sign In
internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn") internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn")
/// Sign Up /// Sign Up
@ -90,6 +96,10 @@ internal enum L10n {
internal enum Firendship { internal enum Firendship {
/// Block /// Block
internal static let block = L10n.tr("Localizable", "Common.Controls.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 /// Blocked
internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked")
/// Block %@ /// Block %@
@ -112,6 +122,8 @@ internal enum L10n {
} }
/// Pending /// Pending
internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.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 /// Unblock
internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock") internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock")
/// Unblock %@ /// Unblock %@
@ -179,8 +191,12 @@ internal enum L10n {
internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning")
/// No Status Found /// No Status Found
internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") 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") 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 { internal enum Loader {
/// Loading missing posts... /// Loading missing posts...
@ -299,6 +315,10 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title") 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 { internal enum Hashtag {
/// %@ people talking /// %@ people talking
internal static func prompt(_ p1: Any) -> String { internal static func prompt(_ p1: Any) -> String {
@ -320,6 +340,10 @@ internal enum L10n {
} }
} }
internal enum Profile { internal enum Profile {
/// %@ posts
internal static func subtitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Profile.Subtitle", String(describing: p1))
}
internal enum Dashboard { internal enum Dashboard {
/// followers /// followers
internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers") internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers")

View File

@ -26,7 +26,7 @@ extension AvatarConfigurableView {
if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 { if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 {
return placeholderImage return placeholderImage
.af.imageAspectScaled(toFill: Self.configurableAvatarImageSize) .af.imageAspectScaled(toFill: Self.configurableAvatarImageSize)
.af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true) .af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: true)
} else { } else {
return placeholderImage.af.imageRoundedIntoCircle() return placeholderImage.af.imageRoundedIntoCircle()
} }
@ -50,11 +50,20 @@ extension AvatarConfigurableView {
defer { defer {
avatarConfigurableView(self, didFinishConfiguration: configuration) avatarConfigurableView(self, didFinishConfiguration: configuration)
} }
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
// set placeholder if no asset // set placeholder if no asset
guard let avatarImageURL = configuration.avatarImageURL else { guard let avatarImageURL = configuration.avatarImageURL else {
configurableAvatarImageView?.image = placeholderImage 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?.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 return
} }
@ -74,7 +83,6 @@ extension AvatarConfigurableView {
avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
default: default:
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarImageView.af.setImage( avatarImageView.af.setImage(
withURL: avatarImageURL, withURL: avatarImageURL,
placeholderImage: placeholderImage, placeholderImage: placeholderImage,
@ -103,7 +111,6 @@ extension AvatarConfigurableView {
avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular
default: default:
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarButton.af.setImage( avatarButton.af.setImage(
for: .normal, for: .normal,
url: avatarImageURL, url: avatarImageURL,

View File

@ -95,7 +95,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
videoPlayerViewModel.didEndDisplaying() 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() self.context.audioPlaybackService.pause()
} }
} }

View File

@ -173,7 +173,7 @@ extension StatusProviderFacade {
return (status.objectID, favoriteKind) return (status.objectID, favoriteKind)
} }
.map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in .map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in
return context.apiService.like( return context.apiService.favorite(
statusObjectID: statusObjectID, statusObjectID: statusObjectID,
mastodonUserObjectID: mastodonUserObjectID, mastodonUserObjectID: mastodonUserObjectID,
favoriteKind: favoriteKind favoriteKind: favoriteKind
@ -201,7 +201,7 @@ extension StatusProviderFacade {
} }
} }
.map { statusID, favoriteKind in .map { statusID, favoriteKind in
return context.apiService.like( return context.apiService.favorite(
statusID: statusID, statusID: statusID,
favoriteKind: favoriteKind, favoriteKind: favoriteKind,
mastodonAuthenticationBox: activeMastodonAuthenticationBox mastodonAuthenticationBox: activeMastodonAuthenticationBox

View File

@ -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)
}
}

View File

@ -7,6 +7,30 @@
import UIKit import UIKit
protocol TableViewCellHeightCacheableContainer: UIViewController { protocol TableViewCellHeightCacheableContainer: StatusProvider {
// TODO: 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)
}
} }

View File

@ -139,7 +139,10 @@ extension UserProviderFacade {
for mastodonUser: MastodonUser, for mastodonUser: MastodonUser,
isMuting: Bool, isMuting: Bool,
isBlocking: Bool, isBlocking: Bool,
provider: UserProvider needsShareAction: Bool,
provider: UserProvider,
sourceView: UIView?,
barButtonItem: UIBarButtonItem?
) -> UIMenu { ) -> UIMenu {
var children: [UIMenuElement] = [] var children: [UIMenuElement] = []
let name = mastodonUser.displayNameWithFallback let name = mastodonUser.displayNameWithFallback
@ -198,7 +201,32 @@ extension UserProviderFacade {
children.append(blockMenu) 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) 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
}
} }

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -24,11 +24,14 @@ Please check your internet connection.";
"Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.Save" = "Save";
"Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SavePhoto" = "Save photo";
"Common.Controls.Actions.SeeMore" = "See More"; "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.SignIn" = "Sign In";
"Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.SignUp" = "Sign Up";
"Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Actions.TryAgain" = "Try Again";
"Common.Controls.Firendship.Block" = "Block"; "Common.Controls.Firendship.Block" = "Block";
"Common.Controls.Firendship.BlockDomain" = "Block %@";
"Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.BlockUser" = "Block %@";
"Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.Blocked" = "Blocked";
"Common.Controls.Firendship.EditInfo" = "Edit info"; "Common.Controls.Firendship.EditInfo" = "Edit info";
@ -38,6 +41,7 @@ Please check your internet connection.";
"Common.Controls.Firendship.MuteUser" = "Mute %@"; "Common.Controls.Firendship.MuteUser" = "Mute %@";
"Common.Controls.Firendship.Muted" = "Muted"; "Common.Controls.Firendship.Muted" = "Muted";
"Common.Controls.Firendship.Pending" = "Pending"; "Common.Controls.Firendship.Pending" = "Pending";
"Common.Controls.Firendship.Request" = "Request";
"Common.Controls.Firendship.Unblock" = "Unblock"; "Common.Controls.Firendship.Unblock" = "Unblock";
"Common.Controls.Firendship.UnblockUser" = "Unblock %@"; "Common.Controls.Firendship.UnblockUser" = "Unblock %@";
"Common.Controls.Firendship.Unmute" = "Unmute"; "Common.Controls.Firendship.Unmute" = "Unmute";
@ -60,7 +64,8 @@ Please check your internet connection.";
until you unblock them. until you unblock them.
Your account looks like this to them."; Your account looks like this to them.";
"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; "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.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Multiple" = "photos";
@ -102,6 +107,7 @@ uploaded to Mastodon.";
"Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, "Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@,
tap the link to confirm your account."; tap the link to confirm your account.";
"Scene.ConfirmEmail.Title" = "One last thing."; "Scene.ConfirmEmail.Title" = "One last thing.";
"Scene.Favorite.Title" = "Your Favorites";
"Scene.Hashtag.Prompt" = "%@ people talking"; "Scene.Hashtag.Prompt" = "%@ people talking";
"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts";
"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline";
@ -118,6 +124,7 @@ tap the link to confirm your account.";
"Scene.Profile.SegmentedControl.Media" = "Media"; "Scene.Profile.SegmentedControl.Media" = "Media";
"Scene.Profile.SegmentedControl.Posts" = "Posts"; "Scene.Profile.SegmentedControl.Posts" = "Posts";
"Scene.Profile.SegmentedControl.Replies" = "Replies"; "Scene.Profile.SegmentedControl.Replies" = "Replies";
"Scene.Profile.Subtitle" = "%@ posts";
"Scene.PublicTimeline.Title" = "Public"; "Scene.PublicTimeline.Title" = "Public";
"Scene.Register.Error.Item.Agreement" = "Agreement"; "Scene.Register.Error.Item.Agreement" = "Agreement";
"Scene.Register.Error.Item.Email" = "Email"; "Scene.Register.Error.Item.Email" = "Email";

View File

@ -41,7 +41,7 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency {
let refreshControl = UIRefreshControl() let refreshControl = UIRefreshControl()
let titleView = HashtagTimelineNavigationBarTitleView() let titleView = DoubleTitleLabelNavigationBarTitleView()
deinit { deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
@ -54,7 +54,7 @@ extension HashtagTimelineViewController {
super.viewDidLoad() super.viewDidLoad()
title = "#\(viewModel.hashtag)" title = "#\(viewModel.hashtag)"
titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil) titleView.update(title: viewModel.hashtag, subtitle: nil)
navigationItem.titleView = titleView navigationItem.titleView = titleView
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
@ -107,13 +107,13 @@ extension HashtagTimelineViewController {
self?.updatePromptTitle() self?.updatePromptTitle()
} }
.store(in: &disposeBag) .store(in: &disposeBag)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
aspectViewWillAppear(animated)
viewModel.fetchTag() viewModel.fetchTag()
guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return }
@ -123,8 +123,8 @@ extension HashtagTimelineViewController {
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
context.videoPlaybackService.viewDidDisappear(from: self)
context.audioPlaybackService.viewDidDisappear(from: self) aspectViewDidDisappear(animated)
} }
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
@ -142,7 +142,7 @@ extension HashtagTimelineViewController {
private func updatePromptTitle() { private func updatePromptTitle() {
var subtitle: String? var subtitle: String?
defer { defer {
titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle) titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle)
} }
guard let histories = viewModel.hashtagEntity.value?.history else { guard let histories = viewModel.hashtagEntity.value?.history else {
return return
@ -158,9 +158,10 @@ extension HashtagTimelineViewController {
.prefix(2) .prefix(2)
.compactMap({ Int($0.accounts) }) .compactMap({ Int($0.accounts) })
.reduce(0, +) .reduce(0, +)
subtitle = "\(peopleTalkingNumber)" subtitle = L10n.Scene.Hashtag.prompt("\(peopleTalkingNumber)")
} }
} }
} }
extension HashtagTimelineViewController { 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 // MARK: - UIScrollViewDelegate
extension HashtagTimelineViewController { extension HashtagTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView) aspectScrollViewDidScroll(scrollView)
// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView)
} }
} }
@ -197,25 +207,16 @@ extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer
// MARK: - UITableViewDelegate // MARK: - UITableViewDelegate
extension HashtagTimelineViewController: UITableViewDelegate { extension HashtagTimelineViewController: UITableViewDelegate {
// TODO: func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
// guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } }
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
//
// guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
// return 200
// }
// // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
//
// return ceil(frame.height)
// }
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { 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) { 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 // MARK: - UITableViewDataSourcePrefetching
extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { extension HashtagTimelineViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { 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 { extension HashtagTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
} }
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
} }
} }

View File

@ -35,6 +35,8 @@ extension HashtagTimelineViewModel.LoadOldestState {
} }
class Loading: HashtagTimelineViewModel.LoadOldestState { class Loading: HashtagTimelineViewModel.LoadOldestState {
var maxID: String?
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self 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 // TODO: only set large count when using Wi-Fi
let maxID = last.id let maxID = self.maxID ?? last.id
viewModel.context.apiService.hashtagTimeline( viewModel.context.apiService.hashtagTimeline(
domain: activeMastodonAuthenticationBox.domain, domain: activeMastodonAuthenticationBox.domain,
maxID: maxID, maxID: maxID,
@ -71,10 +73,19 @@ extension HashtagTimelineViewModel.LoadOldestState {
// handle isFetchingLatestTimeline in fetch controller delegate // handle isFetchingLatestTimeline in fetch controller delegate
break break
} }
} receiveValue: { response in } receiveValue: { [weak self] response in
guard let self = self else { return }
let statuses = response.value let statuses = response.value
// enter no more state when no new statuses // 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) stateMachine.enter(NoMore.self)
} else { } else {
stateMachine.enter(Idle.self) stateMachine.enter(Idle.self)

View File

@ -21,10 +21,18 @@ extension HomeTimelineViewController {
children: [ children: [
moveMenu, moveMenu,
dropMenu, 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 UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
guard let self = self else { return } guard let self = self else { return }
self.showPublicTimelineAction(action) 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 UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return } guard let self = self else { return }
self.signOutAction(action) self.signOutAction(action)
@ -273,9 +281,28 @@ extension HomeTimelineViewController {
.store(in: &disposeBag) .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) { @objc private func showPublicTimelineAction(_ sender: UIAction) {
coordinator.present(scene: .publicTimeline, from: self, transition: .show) 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 #endif

View File

@ -66,6 +66,17 @@ extension MastodonPickServerViewController {
setupOnboardingAppearance() setupOnboardingAppearance()
defer { setupNavigationBarBackgroundView() } 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) view.addSubview(nextStepButton)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([

View File

@ -75,6 +75,14 @@ class MastodonPickServerViewModel: NSObject {
configure() configure()
} }
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension MastodonPickServerViewModel {
private func configure() { private func configure() {
Publishers.CombineLatest( Publishers.CombineLatest(
filteredIndexedServers.eraseToAnyPublisher(), filteredIndexedServers.eraseToAnyPublisher(),

View File

@ -42,7 +42,7 @@ extension MastodonRegisterViewController {
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) 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 { DispatchQueue.main.async {
let cropController = CropViewController(croppingStyle: .default, image: image) let cropController = CropViewController(croppingStyle: .default, image: image)
cropController.delegate = self cropController.delegate = self

View File

@ -13,6 +13,9 @@ import PhotosUI
import UIKit import UIKit
final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance {
static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400)
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } 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 displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value
let avatar: Mastodon.Query.MediaAttachment? = { let avatar: Mastodon.Query.MediaAttachment? = {
guard let avatarImage = self.viewModel.avatarImage.value else { return nil } guard let avatarImage = self.viewModel.avatarImage.value else { return nil }
guard avatarImage.size.width <= 400 else { guard avatarImage.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else {
return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8)) 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( return Mastodon.API.Account.UpdateCredentialQuery(
displayName: displayName, displayName: displayName,

View File

@ -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
}
}

View File

@ -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 }
}

View File

@ -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)
}
}

View File

@ -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
}
}
}
}

View File

@ -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)
}
}

View File

@ -7,6 +7,11 @@
import os.log import os.log
import UIKit import UIKit
import Combine
import PhotosUI
import AlamofireImage
import CropViewController
import TwitterTextEditor
protocol ProfileHeaderViewControllerDelegate: class { protocol ProfileHeaderViewControllerDelegate: class {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
@ -19,8 +24,21 @@ final class ProfileHeaderViewController: UIViewController {
static let segmentedControlMarginHeight: CGFloat = 20 static let segmentedControlMarginHeight: CGFloat = 20
static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight
var disposeBag = Set<AnyCancellable>()
weak var delegate: ProfileHeaderViewControllerDelegate? 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 profileHeaderView = ProfileHeaderView()
let pageSegmentedControl: UISegmentedControl = { let pageSegmentedControl: UISegmentedControl = {
let segmenetedControl = UISegmentedControl(items: ["A", "B"]) let segmenetedControl = UISegmentedControl(items: ["A", "B"])
@ -33,6 +51,28 @@ final class ProfileHeaderViewController: UIViewController {
// private var isAdjustBannerImageViewForSafeAreaInset = false // private var isAdjustBannerImageViewForSafeAreaInset = false
private var containerSafeAreaInset: UIEdgeInsets = .zero 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 { deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -67,11 +107,99 @@ extension ProfileHeaderViewController {
]) ])
pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) 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) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true
// Deprecated: // Deprecated:
// not needs this tweak due to force layout update in the parent // not needs this tweak due to force layout update in the parent
// if !isAdjustBannerImageViewForSafeAreaInset { // if !isAdjustBannerImageViewForSafeAreaInset {
@ -85,11 +213,52 @@ extension ProfileHeaderViewController {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) 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 { extension ProfileHeaderViewController {
@objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) { @objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) {
@ -105,6 +274,15 @@ extension ProfileHeaderViewController {
containerSafeAreaInset = inset 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) { private func updateHeaderBottomShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1) let alpha = min(max(0, 10 * progress - 9), 1)
if bottomShadowAlpha != alpha { if bottomShadowAlpha != alpha {
@ -125,20 +303,133 @@ extension ProfileHeaderViewController {
let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil)
let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height
// scroll from bottom to top: 1 -> 2 -> 3
if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {
// 1
// banner top pin to window top and expand
bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y
bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height
} else if bannerContainerBottomOffset < containerSafeAreaInset.top { } 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 bannerImageView.frame.origin.y = -containerSafeAreaInset.top
let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset)
bannerImageView.frame.size.height = bannerImageHeight bannerImageView.frame.size.height = bannerImageHeight
} else { } 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.origin.y = -containerSafeAreaInset.top
bannerImageView.frame.size.height = bannerContainerInWindow.size.height + 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)
}
}

View File

@ -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
)
}
}

View File

@ -8,6 +8,7 @@
import os.log import os.log
import UIKit import UIKit
import ActiveLabel import ActiveLabel
import TwitterTextEditor
protocol ProfileHeaderViewDelegate: class { protocol ProfileHeaderViewDelegate: class {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) 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 friendshipActionButtonSize = CGSize(width: 108, height: 34)
static let bannerImageViewPlaceholderColor = UIColor.systemGray 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? weak var delegate: ProfileHeaderViewDelegate?
var state: State?
let bannerContainerView = UIView() let bannerContainerView = UIView()
let bannerImageView: UIImageView = { let bannerImageView: UIImageView = {
let imageView = UIImageView() let imageView = UIImageView()
@ -41,7 +47,7 @@ final class ProfileHeaderView: UIView {
}() }()
let bannerImageViewOverlayView: UIView = { let bannerImageViewOverlayView: UIView = {
let overlayView = UIView() let overlayView = UIView()
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5) overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
return overlayView return overlayView
}() }()
@ -53,16 +59,40 @@ final class ProfileHeaderView: UIView {
imageView.image = placeholderImage imageView.image = placeholderImage
return imageView return imageView
}() }()
let editAvatarBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.black.withAlphaComponent(0.6)
view.layer.masksToBounds = true
view.layer.cornerCurve = .continuous
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
return view
}()
let editAvatarButton: HighlightDimmableButton = {
let button = HighlightDimmableButton()
button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal)
button.tintColor = .white
return button
}()
let nameLabel: UILabel = { let nameTextFieldBackgroundView: UIView = {
let label = UILabel() let view = UIView()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) view.layer.masksToBounds = true
label.adjustsFontSizeToFitWidth = true view.layer.cornerCurve = .continuous
label.minimumScaleFactor = 0.5 view.layer.cornerRadius = 10
label.textColor = .white return view
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 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 = { let usernameLabel: UILabel = {
@ -84,9 +114,29 @@ final class ProfileHeaderView: UIView {
}() }()
let bioContainerView = UIView() let bioContainerView = UIView()
let bioContainerStackView = UIStackView()
let fieldContainerStackView = 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 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) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -137,12 +187,32 @@ extension ProfileHeaderView {
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1), avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
]) ])
editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.addSubview(editAvatarBackgroundView)
NSLayoutConstraint.activate([
editAvatarBackgroundView.topAnchor.constraint(equalTo: avatarImageView.topAnchor),
editAvatarBackgroundView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor),
editAvatarBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
])
editAvatarButton.translatesAutoresizingMaskIntoConstraints = false
editAvatarBackgroundView.addSubview(editAvatarButton)
NSLayoutConstraint.activate([
editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor),
editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor),
editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor),
editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor),
])
editAvatarBackgroundView.isUserInteractionEnabled = true
avatarImageView.isUserInteractionEnabled = true
// name container: [display name | username] // name container: [display name container | username]
let nameContainerStackView = UIStackView() let nameContainerStackView = UIStackView()
nameContainerStackView.preservesSuperviewLayoutMargins = true nameContainerStackView.preservesSuperviewLayoutMargins = true
nameContainerStackView.axis = .vertical nameContainerStackView.axis = .vertical
nameContainerStackView.spacing = 0 nameContainerStackView.spacing = 7
nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(nameContainerStackView) addSubview(nameContainerStackView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -150,7 +220,27 @@ extension ProfileHeaderView {
nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), 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) nameContainerStackView.addArrangedSubview(usernameLabel)
// meta container: [dashboard container | bio container | field container] // meta container: [dashboard container | bio container | field container]
@ -192,15 +282,29 @@ extension ProfileHeaderView {
bioContainerView.preservesSuperviewLayoutMargins = true bioContainerView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addArrangedSubview(bioContainerView) metaContainerStackView.addArrangedSubview(bioContainerView)
bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false
bioContainerView.addSubview(bioActiveLabel) bioContainerStackView.translatesAutoresizingMaskIntoConstraints = false
bioContainerView.addSubview(bioContainerStackView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor), bioContainerStackView.topAnchor.constraint(equalTo: bioContainerView.topAnchor),
bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), bioContainerStackView.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor),
bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), bioContainerStackView.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor),
bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), 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 fieldContainerStackView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addSubview(fieldContainerStackView) metaContainerStackView.addSubview(fieldContainerStackView)
@ -210,10 +314,58 @@ extension ProfileHeaderView {
bioActiveLabel.delegate = self bioActiveLabel.delegate = self
relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) 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 { extension ProfileHeaderView {
@objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)

View File

@ -9,6 +9,12 @@ import UIKit
final class ProfileRelationshipActionButton: RoundedEdgesButton { final class ProfileRelationshipActionButton: RoundedEdgesButton {
let actvityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.color = .white
return activityIndicatorView
}()
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
_init() _init()
@ -23,7 +29,15 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton {
extension ProfileRelationshipActionButton { extension ProfileRelationshipActionButton {
private func _init() { 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) setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .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 isEnabled = false
} else if actionOptionSet.contains(.updating) {
isEnabled = false
actvityIndicatorView.startAnimating()
} else { } else {
isEnabled = true isEnabled = true
} }

View File

@ -22,7 +22,7 @@ final class MeProfileViewModel: ProfileViewModel {
self.currentMastodonUser self.currentMastodonUser
.sink { [weak self] currentMastodonUser in .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 } guard let self = self else { return }
self.mastodonUser.value = currentMastodonUser self.mastodonUser.value = currentMastodonUser

View File

@ -18,6 +18,30 @@ final class ProfileViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var viewModel: ProfileViewModel! 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 = { private(set) lazy var replyBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:)))
barButtonItem.tintColor = .white barButtonItem.tintColor = .white
@ -54,12 +78,20 @@ final class ProfileViewController: UIViewController, NeedsDependency {
}() }()
private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController() 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 profileBannerImageViewLayoutConstraint: NSLayoutConstraint!
private var contentOffsets: [Int: CGFloat] = [:] private var contentOffsets: [Int: CGFloat] = [:]
var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation?
// title view nested in header
var titleView: DoubleTitleLabelNavigationBarTitleView {
profileHeaderViewController.titleView
}
deinit { deinit {
os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function) 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.compactAppearance = barAppearance
navigationItem.scrollEdgeAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance
navigationItem.titleView = UIView() navigationItem.titleView = titleView
let editingAndUpdatingPublisher = Publishers.CombineLatest(
viewModel.isEditing.eraseToAnyPublisher(),
viewModel.isUpdating.eraseToAnyPublisher()
)
// note: not add .share() here
Publishers.CombineLatest( let barButtonItemHiddenPublisher = Publishers.CombineLatest3(
viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(),
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
) )
editingAndUpdatingPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, isUpdating in
guard let self = self else { return }
self.cancelEditingBarButtonItem.isEnabled = !isUpdating
}
.store(in: &disposeBag)
Publishers.CombineLatest3 (
viewModel.suspended.eraseToAnyPublisher(),
editingAndUpdatingPublisher.eraseToAnyPublisher(),
barButtonItemHiddenPublisher.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in .sink { [weak self] suspended, tuple1, tuple2 in
guard let self = self else { return } guard let self = self else { return }
let (isEditing, _) = tuple1
let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2
var items: [UIBarButtonItem] = [] 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 { if !isReplyBarButtonItemHidden {
items.append(self.replyBarButtonItem) items.append(self.replyBarButtonItem)
} }
if !isMoreMenuBarButtonItemHidden { if !isMoreMenuBarButtonItemHidden {
items.append(self.moreMenuBarButtonItem) items.append(self.moreMenuBarButtonItem)
} }
guard !items.isEmpty else {
self.navigationItem.rightBarButtonItems = nil
return
}
self.navigationItem.rightBarButtonItems = items
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -225,6 +296,23 @@ extension ProfileViewController {
profileSegmentedViewController.pagingViewController.pagingDelegate = self profileSegmentedViewController.pagingViewController.pagingDelegate = self
// bind view model // 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 viewModel.name
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] name in .sink { [weak self] name in
@ -263,22 +351,15 @@ extension ProfileViewController {
) )
} }
.store(in: &disposeBag) .store(in: &disposeBag)
Publishers.CombineLatest( viewModel.avatarImageURL
viewModel.avatarImageURL.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] avatarImageURL, _ in
guard let self = self else { return }
self.profileHeaderViewController.profileHeaderView.configure(
with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2)
)
}
.store(in: &disposeBag)
viewModel.name
.map { $0 ?? " " }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel) .map { url in ProfileHeaderViewModel.ProfileInfo.ImageResource.url(url) }
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.avatarImageResource)
.store(in: &disposeBag)
viewModel.name
.map { $0 ?? "" }
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
.store(in: &disposeBag) .store(in: &disposeBag)
viewModel.username viewModel.username
.map { username in username.flatMap { "@" + $0 } ?? " " } .map { username in username.flatMap { "@" + $0 } ?? " " }
@ -295,7 +376,8 @@ extension ProfileViewController {
} }
let isMuting = relationshipActionOptionSet.contains(.muting) let isMuting = relationshipActionOptionSet.contains(.muting)
let isBlocking = relationshipActionOptionSet.contains(.blocking) 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) .store(in: &disposeBag)
viewModel.isRelationshipActionButtonHidden viewModel.isRelationshipActionButtonHidden
@ -305,27 +387,60 @@ extension ProfileViewController {
self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden
} }
.store(in: &disposeBag) .store(in: &disposeBag)
Publishers.CombineLatest( Publishers.CombineLatest3(
viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), viewModel.relationshipActionOptionSet.eraseToAnyPublisher(),
viewModel.isEditing.eraseToAnyPublisher() viewModel.isEditing.eraseToAnyPublisher(),
viewModel.isUpdating.eraseToAnyPublisher()
) )
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] relationshipActionSet, isEditing in .sink { [weak self] relationshipActionSet, isEditing, isUpdating in
guard let self = self else { return } guard let self = self else { return }
let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton
if relationshipActionSet.contains(.edit) { 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 { } else {
friendshipButton.configure(actionOptionSet: relationshipActionSet) friendshipButton.configure(actionOptionSet: relationshipActionSet)
} }
} }
.store(in: &disposeBag) .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 viewModel.bioDescription
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] bio in .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.note)
guard let self = self else { return }
self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "")
})
.store(in: &disposeBag) .store(in: &disposeBag)
viewModel.statusesCount viewModel.statusesCount
.sink { [weak self] count in .sink { [weak self] count in
@ -374,6 +489,7 @@ extension ProfileViewController {
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
currentPostTimelineTableViewContentSizeObservation = nil currentPostTimelineTableViewContentSizeObservation = nil
} }
@ -386,12 +502,45 @@ extension ProfileViewController {
viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag) viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag)
viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag)
viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).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 { 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) { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let mastodonUser = viewModel.mastodonUser.value else { return } guard let mastodonUser = viewModel.mastodonUser.value else { return }
@ -414,11 +563,6 @@ extension ProfileViewController {
sender.endRefreshing() sender.endRefreshing()
} }
} }
// @objc private func avatarButtonPressed(_ sender: UIButton) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController))
// }
} }
@ -436,10 +580,15 @@ extension ProfileViewController: UIScrollViewDelegate {
contentOffsets.removeAll() contentOffsets.removeAll()
} else { } else {
containerScrollView.contentOffset.y = topMaxContentOffsetY containerScrollView.contentOffset.y = topMaxContentOffsetY
if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { if viewModel.needsPagePinToTop.value {
let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y // do nothing
customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY } 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 // elastically banner image
@ -492,22 +641,42 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
let relationshipActionSet = viewModel.relationshipActionOptionSet.value let relationshipActionSet = viewModel.relationshipActionOptionSet.value
if relationshipActionSet.contains(.edit) { if relationshipActionSet.contains(.edit) {
viewModel.isEditing.value.toggle() guard !viewModel.isUpdating.value else { return }
if profileHeaderViewController.viewModel.isProfileInfoEdited() {
viewModel.isUpdating.value = true
profileHeaderViewController.viewModel.updateProfileInfo()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info success", ((#file as NSString).lastPathComponent), #line, #function)
}
self.viewModel.isUpdating.value = false
} receiveValue: { [weak self] _ in
guard let self = self else { return }
self.viewModel.isEditing.value = false
}
.store(in: &disposeBag)
} else {
viewModel.isEditing.value.toggle()
}
} else { } else {
guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return }
switch relationshipAction { switch relationshipAction {
case .none: case .none:
break break
case .follow, .following: case .follow, .reqeust, .pending, .following:
UserProviderFacade.toggleUserFollowRelationship(provider: self) UserProviderFacade.toggleUserFollowRelationship(provider: self)
.sink { _ in .sink { _ in
// TODO: handle error
} receiveValue: { _ in } receiveValue: { _ in
// do nothing
} }
.store(in: &disposeBag) .store(in: &disposeBag)
case .pending:
break
case .muting: case .muting:
guard let mastodonUser = viewModel.mastodonUser.value else { return } guard let mastodonUser = viewModel.mastodonUser.value else { return }
let name = mastodonUser.displayNameWithFallback let name = mastodonUser.displayNameWithFallback
@ -557,9 +726,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
default: default:
assertionFailure() assertionFailure()
} }
} }
} }
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) {

View File

@ -41,10 +41,12 @@ class ProfileViewModel: NSObject {
let followersCount: CurrentValueSubject<Int?, Never> let followersCount: CurrentValueSubject<Int?, Never>
let protected: CurrentValueSubject<Bool?, Never> let protected: CurrentValueSubject<Bool?, Never>
// let suspended: CurrentValueSubject<Bool, Never> let suspended: CurrentValueSubject<Bool, Never>
let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
let isEditing = CurrentValueSubject<Bool, Never>(false) let isEditing = CurrentValueSubject<Bool, Never>(false)
let isUpdating = CurrentValueSubject<Bool, Never>(false)
let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
let isFollowedBy = CurrentValueSubject<Bool, Never>(false) let isFollowedBy = CurrentValueSubject<Bool, Never>(false)
let isMuting = CurrentValueSubject<Bool, Never>(false) let isMuting = CurrentValueSubject<Bool, Never>(false)
let isBlocking = CurrentValueSubject<Bool, Never>(false) let isBlocking = CurrentValueSubject<Bool, Never>(false)
@ -53,6 +55,9 @@ class ProfileViewModel: NSObject {
let isRelationshipActionButtonHidden = CurrentValueSubject<Bool, Never>(true) let isRelationshipActionButtonHidden = CurrentValueSubject<Bool, Never>(true)
let isReplyBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true) let isReplyBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
let isMoreMenuBarButtonItemHidden = 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?) { init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
self.context = context self.context = context
@ -61,7 +66,6 @@ class ProfileViewModel: NSObject {
self.userID = CurrentValueSubject(mastodonUser?.id) self.userID = CurrentValueSubject(mastodonUser?.id)
self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL())
self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL())
// self.protected = CurrentValueSubject(twitterUser?.protected)
self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback)
self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) self.username = CurrentValueSubject(mastodonUser?.acctWithDomain)
self.bioDescription = CurrentValueSubject(mastodonUser?.note) self.bioDescription = CurrentValueSubject(mastodonUser?.note)
@ -70,6 +74,7 @@ class ProfileViewModel: NSObject {
self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) })
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
self.protected = CurrentValueSubject(mastodonUser?.locked) self.protected = CurrentValueSubject(mastodonUser?.locked)
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
super.init() super.init()
relationshipActionOptionSet relationshipActionOptionSet
@ -225,6 +230,7 @@ extension ProfileViewModel {
self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) }
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
self.protected.value = mastodonUser?.locked self.protected.value = mastodonUser?.locked
self.suspended.value = mastodonUser?.suspended ?? false
} }
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
@ -240,6 +246,7 @@ extension ProfileViewModel {
// set bar button item state // set bar button item state
self.isReplyBarButtonItemHidden.value = true self.isReplyBarButtonItemHidden.value = true
self.isMoreMenuBarButtonItemHidden.value = true self.isMoreMenuBarButtonItemHidden.value = true
self.isMeBarButtonItemsHidden.value = true
return return
} }
@ -248,10 +255,19 @@ extension ProfileViewModel {
// set bar button item state // set bar button item state
self.isReplyBarButtonItemHidden.value = true self.isReplyBarButtonItemHidden.value = true
self.isMoreMenuBarButtonItemHidden.value = true self.isMoreMenuBarButtonItemHidden.value = true
self.isMeBarButtonItemsHidden.value = false
} else { } else {
// set with follow action default // set with follow action default
var relationshipActionSet = RelationshipActionOptionSet([.follow]) 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 let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isFollowing { if isFollowing {
relationshipActionSet.insert(.following) relationshipActionSet.insert(.following)
@ -294,6 +310,7 @@ extension ProfileViewModel {
// set bar button item state // set bar button item state
self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy
self.isMoreMenuBarButtonItemHidden.value = false self.isMoreMenuBarButtonItemHidden.value = false
self.isMeBarButtonItemsHidden.value = true
} }
} }
@ -304,13 +321,16 @@ extension ProfileViewModel {
enum RelationshipAction: Int, CaseIterable { enum RelationshipAction: Int, CaseIterable {
case none // set hide from UI case none // set hide from UI
case follow case follow
case reqeust
case pending case pending
case following case following
case muting case muting
case blocked case blocked
case blocking case blocking
case suspended
case edit case edit
case editing case editing
case updating
var option: RelationshipActionOptionSet { var option: RelationshipActionOptionSet {
return RelationshipActionOptionSet(rawValue: 1 << rawValue) return RelationshipActionOptionSet(rawValue: 1 << rawValue)
@ -323,15 +343,18 @@ extension ProfileViewModel {
static let none = RelationshipAction.none.option static let none = RelationshipAction.none.option
static let follow = RelationshipAction.follow.option static let follow = RelationshipAction.follow.option
static let request = RelationshipAction.reqeust.option
static let pending = RelationshipAction.pending.option static let pending = RelationshipAction.pending.option
static let following = RelationshipAction.following.option static let following = RelationshipAction.following.option
static let muting = RelationshipAction.muting.option static let muting = RelationshipAction.muting.option
static let blocked = RelationshipAction.blocked.option static let blocked = RelationshipAction.blocked.option
static let blocking = RelationshipAction.blocking.option static let blocking = RelationshipAction.blocking.option
static let suspended = RelationshipAction.suspended.option
static let edit = RelationshipAction.edit.option static let edit = RelationshipAction.edit.option
static let editing = RelationshipAction.editing.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? { func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? {
let set = subtracting(except) let set = subtracting(except)
@ -350,13 +373,16 @@ extension ProfileViewModel {
switch highPriorityAction { switch highPriorityAction {
case .none: return " " case .none: return " "
case .follow: return L10n.Common.Controls.Firendship.follow 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 .pending: return L10n.Common.Controls.Firendship.pending
case .following: return L10n.Common.Controls.Firendship.following case .following: return L10n.Common.Controls.Firendship.following
case .muting: return L10n.Common.Controls.Firendship.muted case .muting: return L10n.Common.Controls.Firendship.muted
case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user
case .blocking: return L10n.Common.Controls.Firendship.blocked 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 .edit: return L10n.Common.Controls.Firendship.editInfo
case .editing: return L10n.Common.Controls.Actions.done case .editing: return L10n.Common.Controls.Actions.done
case .updating: return " "
} }
} }
@ -368,13 +394,16 @@ extension ProfileViewModel {
switch highPriorityAction { switch highPriorityAction {
case .none: return Asset.Colors.Button.normal.color case .none: return Asset.Colors.Button.normal.color
case .follow: 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 .pending: return Asset.Colors.Button.normal.color
case .following: return Asset.Colors.Button.normal.color case .following: return Asset.Colors.Button.normal.color
case .muting: return Asset.Colors.Background.alertYellow.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 .blocking: return Asset.Colors.Background.danger.color
case .suspended: return Asset.Colors.Button.normal.color
case .edit: return Asset.Colors.Button.normal.color case .edit: return Asset.Colors.Button.normal.color
case .editing: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color
case .updating: return Asset.Colors.Button.normal.color
} }
} }

View File

@ -57,6 +57,7 @@ extension UserTimelineViewController {
]) ])
tableView.delegate = self tableView.delegate = self
tableView.prefetchDataSource = self
viewModel.setupDiffableDataSource( viewModel.setupDiffableDataSource(
for: tableView, for: tableView,
dependency: self, dependency: self,
@ -80,21 +81,31 @@ extension UserTimelineViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
tableView.deselectRow(with: transitionCoordinator, animated: animated) aspectViewWillAppear(animated)
} }
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
context.videoPlaybackService.viewDidDisappear(from: self) aspectViewDidDisappear(animated)
} }
} }
// MARK: - StatusTableViewControllerAspect
extension UserTimelineViewController: StatusTableViewControllerAspect { }
// MARK: - UIScrollViewDelegate // MARK: - UIScrollViewDelegate
extension UserTimelineViewController { extension UserTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) { 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 { extension UserTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } }
guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if case .bottomLoader = item { aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
return TimelineLoaderTableViewCell.cellHeight
} else {
return 200
}
}
// os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
return ceil(frame.height)
} }
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return } aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
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))
} }
} }
// MARK: - UITableViewDataSourcePrefetching
extension UserTimelineViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
aspectTableView(tableView, prefetchRowsAt: indexPaths)
}
}
// MARK: - AVPlayerViewControllerDelegate // MARK: - AVPlayerViewControllerDelegate
extension UserTimelineViewController: AVPlayerViewControllerDelegate { extension UserTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
} }
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { 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 } func parent() -> UIViewController { return self }
} }
//// MARK: - TimelineHeaderTableViewCellDelegate
//extension UserTimelineViewController: TimelineHeaderTableViewCellDelegate { }
// MARK: - CustomScrollViewContainerController // MARK: - CustomScrollViewContainerController
extension UserTimelineViewController: ScrollViewContainer { extension UserTimelineViewController: ScrollViewContainer {
var scrollView: UIScrollView { return tableView } var scrollView: UIScrollView { return tableView }
@ -159,7 +160,7 @@ extension UserTimelineViewController: ScrollViewContainer {
// MARK: - LoadMoreConfigurableTableViewContainer // MARK: - LoadMoreConfigurableTableViewContainer
extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = UserTimelineViewModel.State.LoadingMore typealias LoadingState = UserTimelineViewModel.State.Loading
var loadMoreConfigurableTableView: UITableView { return tableView } var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }

View File

@ -40,11 +40,7 @@ extension UserTimelineViewModel.State {
class Reloading: UserTimelineViewModel.State { class Reloading: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass { switch stateClass {
case is Fail.Type: case is Loading.Type:
return true
case is Idle.Type:
return true
case is NoMore.Type:
return true return true
default: default:
return false return false
@ -57,69 +53,38 @@ extension UserTimelineViewModel.State {
// reset // reset
viewModel.statusFetchedResultsController.statusIDs.value = [] viewModel.statusFetchedResultsController.statusIDs.value = []
guard let userID = viewModel.userID.value, !userID.isEmpty else { stateMachine.enter(Loading.self)
stateMachine.enter(Fail.self)
return
}
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let domain = activeMastodonAuthenticationBox.domain
let queryFilter = viewModel.queryFilter.value
viewModel.context.apiService.userTimeline(
domain: domain,
accountID: userID,
maxID: nil,
sinceID: nil,
excludeReplies: queryFilter.excludeReplies,
excludeReblogs: queryFilter.excludeReblogs,
onlyMedia: queryFilter.onlyMedia,
authorizationBox: activeMastodonAuthenticationBox
)
.receive(on: DispatchQueue.main)
.sink { completion in
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
hasNewStatusesAppend = true
}
if hasNewStatusesAppend {
stateMachine.enter(Idle.self)
} else {
stateMachine.enter(NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
}
.store(in: &viewModel.disposeBag)
} }
} }
class Fail: UserTimelineViewModel.State { class Fail: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass { switch stateClass {
case is Reloading.Type, is LoadingMore.Type: case is Loading.Type:
return true return true
default: default:
return false 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 { class Idle: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass { switch stateClass {
case is Reloading.Type, is LoadingMore.Type: case is Reloading.Type, is Loading.Type:
return true return true
default: default:
return false return false
@ -127,7 +92,7 @@ extension UserTimelineViewModel.State {
} }
} }
class LoadingMore: UserTimelineViewModel.State { class Loading: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass { switch stateClass {
case is Fail.Type: case is Fail.Type:
@ -145,10 +110,7 @@ extension UserTimelineViewModel.State {
super.didEnter(from: previousState) super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return } guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last else { let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last
stateMachine.enter(Fail.self)
return
}
guard let userID = viewModel.userID.value, !userID.isEmpty else { guard let userID = viewModel.userID.value, !userID.isEmpty else {
stateMachine.enter(Fail.self) stateMachine.enter(Fail.self)
@ -177,6 +139,7 @@ extension UserTimelineViewModel.State {
switch completion { switch completion {
case .failure(let error): 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) 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: case .finished:
break break
} }

View File

@ -12,9 +12,8 @@ import Combine
import CoreData import CoreData
import CoreDataStack import CoreDataStack
import MastodonSDK import MastodonSDK
import AlamofireImage
class UserTimelineViewModel: NSObject { final class UserTimelineViewModel {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
@ -28,6 +27,8 @@ class UserTimelineViewModel: NSObject {
let isBlocking = CurrentValueSubject<Bool, Never>(false) let isBlocking = CurrentValueSubject<Bool, Never>(false)
let isBlockedBy = 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 // output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
@ -37,7 +38,7 @@ class UserTimelineViewModel: NSObject {
State.Reloading(viewModel: self), State.Reloading(viewModel: self),
State.Fail(viewModel: self), State.Fail(viewModel: self),
State.Idle(viewModel: self), State.Idle(viewModel: self),
State.LoadingMore(viewModel: self), State.Loading(viewModel: self),
State.NoMore(viewModel: self), State.NoMore(viewModel: self),
]) ])
stateMachine.enter(State.Initial.self) stateMachine.enter(State.Initial.self)
@ -54,20 +55,21 @@ class UserTimelineViewModel: NSObject {
self.domain = CurrentValueSubject(domain) self.domain = CurrentValueSubject(domain)
self.userID = CurrentValueSubject(userID) self.userID = CurrentValueSubject(userID)
self.queryFilter = CurrentValueSubject(queryFilter) self.queryFilter = CurrentValueSubject(queryFilter)
super.init() // super.init()
self.domain self.domain
.assign(to: \.value, on: statusFetchedResultsController.domain) .assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag) .store(in: &disposeBag)
Publishers.CombineLatest3( Publishers.CombineLatest4(
statusFetchedResultsController.objectIDs.eraseToAnyPublisher(), statusFetchedResultsController.objectIDs.eraseToAnyPublisher(),
isBlocking.eraseToAnyPublisher(), isBlocking.eraseToAnyPublisher(),
isBlockedBy.eraseToAnyPublisher() isBlockedBy.eraseToAnyPublisher(),
isSuspended.eraseToAnyPublisher()
) )
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.debounce(for: .milliseconds(300), scheduler: 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 self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return } guard let diffableDataSource = self.diffableDataSource else { return }
@ -90,6 +92,12 @@ class UserTimelineViewModel: NSObject {
return 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] = [:] var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
let oldSnapshot = diffableDataSource.snapshot() let oldSnapshot = diffableDataSource.snapshot()
for item in oldSnapshot.itemIdentifiers { for item in oldSnapshot.itemIdentifiers {
@ -105,7 +113,7 @@ class UserTimelineViewModel: NSObject {
if let currentState = self.stateMachine.currentState { if let currentState = self.stateMachine.currentState {
switch 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) snapshot.appendItems([.bottomLoader], toSection: .main)
case is State.NoMore: case is State.NoMore:
break break
@ -114,8 +122,6 @@ class UserTimelineViewModel: NSObject {
break break
} }
} }
} }
.store(in: &disposeBag) .store(in: &disposeBag)
} }
@ -144,4 +150,3 @@ extension UserTimelineViewModel {
} }
} }

View File

@ -1,5 +1,5 @@
// //
// HashtagTimelineTitleView.swift // DoubleTitleLabelNavigationBarTitleView.swift
// Mastodon // Mastodon
// //
// Created by BradGao on 2021/4/1. // Created by BradGao on 2021/4/1.
@ -7,7 +7,7 @@
import UIKit import UIKit
final class HashtagTimelineNavigationBarTitleView: UIView { final class DoubleTitleLabelNavigationBarTitleView: UIView {
let containerView = UIStackView() let containerView = UIStackView()
@ -40,7 +40,7 @@ final class HashtagTimelineNavigationBarTitleView: UIView {
} }
extension HashtagTimelineNavigationBarTitleView { extension DoubleTitleLabelNavigationBarTitleView {
private func _init() { private func _init() {
containerView.axis = .vertical containerView.axis = .vertical
containerView.alignment = .center containerView.alignment = .center
@ -58,10 +58,10 @@ extension HashtagTimelineNavigationBarTitleView {
containerView.addArrangedSubview(subtitleLabel) containerView.addArrangedSubview(subtitleLabel)
} }
func updateTitle(hashtag: String, peopleNumber: String?) { func update(title: String, subtitle: String?) {
titleLabel.text = "#\(hashtag)" titleLabel.text = title
if let peopleNumebr = peopleNumber { if let subtitle = subtitle {
subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr) subtitleLabel.text = subtitle
subtitleLabel.isHidden = false subtitleLabel.isHidden = false
} else { } else {
subtitleLabel.text = nil 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

View File

@ -84,8 +84,10 @@ extension TimelineHeaderView {
extension Item.EmptyStateHeaderAttribute.Reason { extension Item.EmptyStateHeaderAttribute.Reason {
var iconImage: UIImage? { var iconImage: UIImage? {
switch self { switch self {
case .noStatusFound, .blocking, .blocked, .suspended: case .noStatusFound, .blocking, .blocked:
return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))!
case .suspended:
return UIImage(systemName: "person.crop.circle.badge.xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))!
} }
} }
@ -97,8 +99,12 @@ extension Item.EmptyStateHeaderAttribute.Reason {
return L10n.Common.Controls.Timeline.Header.blockingWarning return L10n.Common.Controls.Timeline.Header.blockingWarning
case .blocked: case .blocked:
return L10n.Common.Controls.Timeline.Header.blockedWarning return L10n.Common.Controls.Timeline.Header.blockedWarning
case .suspended: case .suspended(let name):
return L10n.Common.Controls.Timeline.Header.suspendedWarning if let name = name {
return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name)
} else {
return L10n.Common.Controls.Timeline.Header.suspendedWarning
}
} }
} }
} }

View File

@ -16,7 +16,7 @@ import CommonOSLog
extension APIService { extension APIService {
// make local state change only // make local state change only
func like( func favorite(
statusObjectID: NSManagedObjectID, statusObjectID: NSManagedObjectID,
mastodonUserObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID,
favoriteKind: Mastodon.API.Favorites.FavoriteKind favoriteKind: Mastodon.API.Favorites.FavoriteKind
@ -50,7 +50,7 @@ extension APIService {
} }
// send favorite request to remote // send favorite request to remote
func like( func favorite(
statusID: Mastodon.Entity.Status.ID, statusID: Mastodon.Entity.Status.ID,
favoriteKind: Mastodon.API.Favorites.FavoriteKind, favoriteKind: Mastodon.API.Favorites.FavoriteKind,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
@ -128,16 +128,20 @@ extension APIService {
} }
extension APIService { extension APIService {
func likeList( func favoritedStatuses(
limit: Int = onceRequestStatusMaxCount, limit: Int = onceRequestStatusMaxCount,
userID: String,
maxID: String? = nil, maxID: String? = nil,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> { ) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let requestMastodonUserID = mastodonAuthenticationBox.userID let requestMastodonUserID = mastodonAuthenticationBox.userID
let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) 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) 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 .map { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
let log = OSLog.api let log = OSLog.api

View File

@ -158,6 +158,7 @@ extension APIService {
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
return Mastodon.API.Account.follow( return Mastodon.API.Account.follow(
session: session, session: session,
@ -166,22 +167,50 @@ extension APIService {
followQueryType: followQueryType, followQueryType: followQueryType,
authorization: authorization authorization: authorization
) )
.handleEvents(receiveCompletion: { [weak self] completion in // .handleEvents(receiveCompletion: { [weak self] completion in
guard let _ = self else { return } // guard let _ = self else { return }
switch completion { // switch completion {
case .failure(let error): // case .failure(let error):
// TODO: handle 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) // 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 // break
case .finished: // case .finished:
switch followQueryType { // switch followQueryType {
case .follow: // case .follow:
break // break
case .unfollow: // case .unfollow:
break // 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):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View File

@ -95,6 +95,9 @@ extension APIService.CoreData {
user.update(statusesCount: property.statusesCount) user.update(statusesCount: property.statusesCount)
user.update(followingCount: property.followingCount) user.update(followingCount: property.followingCount)
user.update(followersCount: property.followersCount) 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) user.didUpdate(at: networkDate)
} }

View File

@ -234,8 +234,8 @@ extension APIService.Persist {
let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in
return next.statusProcessType == .create ? result + 1 : result 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: 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: 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: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
} }
#endif #endif
} }

View File

@ -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
}
}

View File

@ -14,78 +14,6 @@ extension Mastodon.API.Favorites {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites") 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 /// Favourited statuses
/// ///
/// Using this endpoint to view the favourited list for user /// Using this endpoint to view the favourited list for user
@ -101,7 +29,12 @@ extension Mastodon.API.Favorites {
/// - session: `URLSession` /// - session: `URLSession`
/// - authorization: User token /// - authorization: User token
/// - Returns: `AnyPublisher` contains `Server` nested in the response /// - 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 url = favoritesStatusesEndpointURL(domain: domain)
let request = Mastodon.API.get(url: url, query: query, authorization: authorization) let request = Mastodon.API.get(url: url, query: query, authorization: authorization)
return session.dataTaskPublisher(for: request) return session.dataTaskPublisher(for: request)
@ -112,16 +45,7 @@ extension Mastodon.API.Favorites {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} public struct FavoriteStatusesQuery: GetQuery, PagedQueryType {
extension Mastodon.API.Favorites {
public enum FavoriteKind {
case create
case destroy
}
public struct ListQuery: GetQuery, PagedQueryType {
public var limit: Int? public var limit: Int?
public var minID: String? 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()
}
}

View File

@ -18,6 +18,7 @@ extension Mastodon.Response {
// application fields // application fields
public let rateLimit: RateLimit? public let rateLimit: RateLimit?
public let link: Link?
public let responseTime: Int? public let responseTime: Int?
public var networkDate: Date { public var networkDate: Date {
@ -33,6 +34,11 @@ extension Mastodon.Response {
}() }()
self.rateLimit = RateLimit(response: 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 = { self.responseTime = {
guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil } guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil }
return Int(string) return Int(string)
@ -43,6 +49,7 @@ extension Mastodon.Response {
self.value = value self.value = value
self.date = old.date self.date = old.date
self.rateLimit = old.rateLimit self.rateLimit = old.rateLimit
self.link = old.link
self.responseTime = old.responseTime 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)
}()
}
}
}

View File

@ -54,6 +54,7 @@ arch -x86_64 pod install
- [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftGen](https://github.com/SwiftGen/SwiftGen)
- [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)
- [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor) - [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor)
- [TwitterProfile](https://github.com/OfTheWolf/TwitterProfile)
- [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder) - [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder)
## License ## License