forked from zelo72/mastodon-ios
Merge pull request #99 from tootsuite/feature/favorite-and-profile-edit
Add profile edit support and favorites scene
This commit is contained in:
commit
152e6d7aad
|
@ -82,6 +82,7 @@
|
||||||
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="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"/>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.",
|
"blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.",
|
||||||
"blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.",
|
"blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.",
|
||||||
"suspended_warning": "This account is suspended."
|
"suspended_warning": "This account has been suspended.",
|
||||||
|
"user_suspended_warning": "%s's account has been suspended."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -217,7 +222,7 @@
|
||||||
"new_posts": "See new posts",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
//
|
||||||
|
// SafariActivity.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-8.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SafariServices
|
||||||
|
|
||||||
|
final class SafariActivity: UIActivity {
|
||||||
|
|
||||||
|
weak var sceneCoordinator: SceneCoordinator?
|
||||||
|
var url: NSURL?
|
||||||
|
|
||||||
|
init(sceneCoordinator: SceneCoordinator) {
|
||||||
|
self.sceneCoordinator = sceneCoordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
override var activityType: UIActivity.ActivityType? {
|
||||||
|
return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity")
|
||||||
|
}
|
||||||
|
|
||||||
|
override var activityTitle: String? {
|
||||||
|
return L10n.Common.Controls.Actions.openInSafari
|
||||||
|
}
|
||||||
|
|
||||||
|
override var activityImage: UIImage? {
|
||||||
|
return UIImage(systemName: "safari")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||||
|
for item in activityItems {
|
||||||
|
guard let _ = item as? NSURL, sceneCoordinator != nil else { continue }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepare(withActivityItems activityItems: [Any]) {
|
||||||
|
for item in activityItems {
|
||||||
|
guard let url = item as? NSURL else { continue }
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var activityViewController: UIViewController? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func perform() {
|
||||||
|
guard let url = url else {
|
||||||
|
activityDidFinish(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil))
|
||||||
|
activityDidFinish(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -33,8 +33,8 @@ extension SceneCoordinator {
|
||||||
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
|
case 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()
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -0,0 +1,123 @@
|
||||||
|
//
|
||||||
|
// StatusTableViewControllerAspect.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-7.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
// Check List Last Updated
|
||||||
|
// - FavoriteViewController: 2021/4/8
|
||||||
|
// - HashtagTimelineViewController: 2021/4/8
|
||||||
|
// - UserTimelineViewController: 2021/4/8
|
||||||
|
// * StatusTableViewControllerAspect: 2021/4/7
|
||||||
|
|
||||||
|
// (Fake) Aspect protocol to group common protocol extension implementations
|
||||||
|
// Needs update related view controller when aspect interface changes
|
||||||
|
|
||||||
|
/// Status related operations aspect
|
||||||
|
/// Please check the aspect methods (Option+Click) and add hook to implement features
|
||||||
|
/// - UI
|
||||||
|
/// - Media
|
||||||
|
/// - Data Source
|
||||||
|
protocol StatusTableViewControllerAspect: UIViewController {
|
||||||
|
var tableView: UITableView { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIViewController [A]
|
||||||
|
|
||||||
|
// [A1] aspectViewWillAppear(_:)
|
||||||
|
extension StatusTableViewControllerAspect {
|
||||||
|
/// [UI] hook to deselect row in the transitioning for the table view
|
||||||
|
func aspectViewWillAppear(_ animated: Bool) {
|
||||||
|
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusTableViewControllerAspect where Self: NeedsDependency {
|
||||||
|
/// [Media] hook to notify video service
|
||||||
|
func aspectViewDidDisappear(_ animated: Bool) {
|
||||||
|
context.videoPlaybackService.viewDidDisappear(from: self)
|
||||||
|
context.audioPlaybackService.viewDidDisappear(from: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDelegate [B]
|
||||||
|
|
||||||
|
// [B1] aspectTableView(_:estimatedHeightForRowAt:)
|
||||||
|
extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer {
|
||||||
|
/// [Data Source] hook to notify table view bottom loader
|
||||||
|
func aspectScrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
handleScrollViewDidScroll(scrollView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [B2] aspectTableView(_:estimatedHeightForRowAt:)
|
||||||
|
extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer {
|
||||||
|
/// [UI] hook to estimate table view cell height from cache
|
||||||
|
func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
handleTableView(tableView, estimatedHeightForRowAt: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [B3] aspectTableView(_:willDisplay:forRowAt:)
|
||||||
|
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||||
|
func aspectTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [B4] StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:)
|
||||||
|
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||||
|
/// [Media] hook to notify video service
|
||||||
|
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer & StatusProvider {
|
||||||
|
/// [UI] hook to cache table view cell height
|
||||||
|
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer & StatusProvider {
|
||||||
|
/// [Media] hook to notify video service
|
||||||
|
/// [UI] hook to cache table view cell height
|
||||||
|
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||||
|
cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSourcePrefetching [C]
|
||||||
|
|
||||||
|
// [C1] aspectTableView(:prefetchRowsAt)
|
||||||
|
extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider {
|
||||||
|
/// [Data Source] hook to prefetch reply to info for status
|
||||||
|
func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
|
handleTableView(tableView, prefetchRowsAt: indexPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPlayerViewControllerDelegate & NeedsDependency [D]
|
||||||
|
|
||||||
|
// [D1] aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:)
|
||||||
|
extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency {
|
||||||
|
/// [Media] hook to mark transitioning to video service
|
||||||
|
func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [D2] aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:)
|
||||||
|
extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency {
|
||||||
|
/// [Media] hook to mark transitioning to video service
|
||||||
|
func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,30 @@
|
||||||
|
|
||||||
import UIKit
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "0.200",
|
||||||
|
"blue" : "128",
|
||||||
|
"green" : "120",
|
||||||
|
"red" : "120"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "0.360",
|
||||||
|
"blue" : "128",
|
||||||
|
"green" : "120",
|
||||||
|
"red" : "120"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "0.360",
|
||||||
|
"blue" : "128",
|
||||||
|
"green" : "120",
|
||||||
|
"red" : "120"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,11 +24,14 @@ Please check your internet connection.";
|
||||||
"Common.Controls.Actions.Save" = "Save";
|
"Common.Controls.Actions.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";
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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([
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -0,0 +1,87 @@
|
||||||
|
//
|
||||||
|
// FavoriteViewController+StatusProvider.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-7.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
// MARK: - StatusProvider
|
||||||
|
extension FavoriteViewController: StatusProvider {
|
||||||
|
|
||||||
|
func status() -> Future<Status?, Never> {
|
||||||
|
return Future { promise in promise(.success(nil)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never> {
|
||||||
|
return Future { promise in
|
||||||
|
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
promise(.success(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
|
||||||
|
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
promise(.success(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item {
|
||||||
|
case .status(let objectID, _):
|
||||||
|
let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||||
|
managedObjectContext.perform {
|
||||||
|
let status = managedObjectContext.object(with: objectID) as? Status
|
||||||
|
promise(.success(status))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
promise(.success(nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
|
||||||
|
return Future { promise in promise(.success(nil)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedObjectContext: NSManagedObjectContext {
|
||||||
|
return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
|
||||||
|
return viewModel.diffableDataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
|
||||||
|
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
|
||||||
|
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func items(indexPaths: [IndexPath]) -> [Item] {
|
||||||
|
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var items: [Item] = []
|
||||||
|
for indexPath in indexPaths {
|
||||||
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
|
||||||
|
items.append(item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
//
|
||||||
|
// FavoriteViewController.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-6.
|
||||||
|
//
|
||||||
|
|
||||||
|
// Note: Prefer use US favorite then EN favourite in coding
|
||||||
|
// to following the text checker auto-correct behavior
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import AVKit
|
||||||
|
import Combine
|
||||||
|
import GameplayKit
|
||||||
|
|
||||||
|
final class FavoriteViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
var viewModel: FavoriteViewModel!
|
||||||
|
|
||||||
|
let titleView = DoubleTitleLabelNavigationBarTitleView()
|
||||||
|
|
||||||
|
lazy var tableView: UITableView = {
|
||||||
|
let tableView = UITableView()
|
||||||
|
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||||
|
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
return tableView
|
||||||
|
}()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FavoriteViewController {
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
navigationItem.titleView = titleView
|
||||||
|
titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil)
|
||||||
|
|
||||||
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(tableView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.prefetchDataSource = self
|
||||||
|
viewModel.setupDiffableDataSource(
|
||||||
|
for: tableView,
|
||||||
|
dependency: self,
|
||||||
|
statusTableViewCellDelegate: self
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
aspectViewWillAppear(animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
|
||||||
|
aspectViewDidDisappear(animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - StatusTableViewControllerAspect
|
||||||
|
extension FavoriteViewController: StatusTableViewControllerAspect { }
|
||||||
|
|
||||||
|
// MARK: - TableViewCellHeightCacheableContainer
|
||||||
|
extension FavoriteViewController: TableViewCellHeightCacheableContainer {
|
||||||
|
var cellFrameCache: NSCache<NSNumber, NSValue> {
|
||||||
|
return viewModel.cellFrameCache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIScrollViewDelegate
|
||||||
|
extension FavoriteViewController {
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
aspectScrollViewDidScroll(scrollView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDelegate
|
||||||
|
extension FavoriteViewController: UITableViewDelegate {
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSourcePrefetching
|
||||||
|
extension FavoriteViewController: UITableViewDataSourcePrefetching {
|
||||||
|
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
|
aspectTableView(tableView, prefetchRowsAt: indexPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPlayerViewControllerDelegate
|
||||||
|
extension FavoriteViewController: AVPlayerViewControllerDelegate {
|
||||||
|
|
||||||
|
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TimelinePostTableViewCellDelegate
|
||||||
|
extension FavoriteViewController: StatusTableViewCellDelegate {
|
||||||
|
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
||||||
|
func parent() -> UIViewController { return self }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LoadMoreConfigurableTableViewContainer
|
||||||
|
extension FavoriteViewController: LoadMoreConfigurableTableViewContainer {
|
||||||
|
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||||
|
typealias LoadingState = FavoriteViewModel.State.Loading
|
||||||
|
|
||||||
|
var loadMoreConfigurableTableView: UITableView { return tableView }
|
||||||
|
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// FavoriteViewModel+Diffable.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-7.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension FavoriteViewModel {
|
||||||
|
|
||||||
|
func setupDiffableDataSource(
|
||||||
|
for tableView: UITableView,
|
||||||
|
dependency: NeedsDependency,
|
||||||
|
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||||
|
) {
|
||||||
|
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||||
|
.autoconnect()
|
||||||
|
.share()
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
|
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||||
|
for: tableView,
|
||||||
|
dependency: dependency,
|
||||||
|
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
|
||||||
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
|
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||||
|
timelineMiddleLoaderTableViewCellDelegate: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// set empty section to make update animation top-to-bottom style
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
diffableDataSource?.apply(snapshot)
|
||||||
|
|
||||||
|
stateMachine.enter(State.Reloading.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
//
|
||||||
|
// FavoriteViewModel+State.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-7.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import GameplayKit
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension FavoriteViewModel {
|
||||||
|
class State: GKState {
|
||||||
|
weak var viewModel: FavoriteViewModel?
|
||||||
|
|
||||||
|
init(viewModel: FavoriteViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FavoriteViewModel.State {
|
||||||
|
class Initial: FavoriteViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
guard let viewModel = viewModel else { return false }
|
||||||
|
switch stateClass {
|
||||||
|
case is Reloading.Type:
|
||||||
|
return viewModel.activeMastodonAuthenticationBox.value != nil
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Reloading: FavoriteViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Loading.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
|
||||||
|
// reset
|
||||||
|
viewModel.statusFetchedResultsController.statusIDs.value = []
|
||||||
|
|
||||||
|
stateMachine.enter(Loading.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fail: FavoriteViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Loading.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let _ = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
stateMachine.enter(Loading.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Idle: FavoriteViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Reloading.Type, is Loading.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loading: FavoriteViewModel.State {
|
||||||
|
|
||||||
|
var maxID: String?
|
||||||
|
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Fail.Type:
|
||||||
|
return true
|
||||||
|
case is Idle.Type:
|
||||||
|
return true
|
||||||
|
case is NoMore.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if previousState is Reloading {
|
||||||
|
maxID = nil
|
||||||
|
}
|
||||||
|
// prefer use `maxID` token in response header
|
||||||
|
// let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last
|
||||||
|
|
||||||
|
viewModel.context.apiService.favoritedStatuses(
|
||||||
|
maxID: maxID,
|
||||||
|
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||||
|
)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
case .finished:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { response in
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
var hasNewStatusesAppend = false
|
||||||
|
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
|
||||||
|
for status in response.value {
|
||||||
|
guard !statusIDs.contains(status.id) else { continue }
|
||||||
|
statusIDs.append(status.id)
|
||||||
|
hasNewStatusesAppend = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.maxID = response.link?.maxID
|
||||||
|
|
||||||
|
let hasNextPage: Bool = {
|
||||||
|
guard let link = response.link else { return true } // assert has more when link invalid
|
||||||
|
return link.maxID != nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
if hasNewStatusesAppend && hasNextPage {
|
||||||
|
stateMachine.enter(Idle.self)
|
||||||
|
} else {
|
||||||
|
stateMachine.enter(NoMore.self)
|
||||||
|
}
|
||||||
|
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoMore: FavoriteViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Reloading.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
//
|
||||||
|
// FavoriteViewModel.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-6.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import GameplayKit
|
||||||
|
|
||||||
|
final class FavoriteViewModel {
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// input
|
||||||
|
let context: AppContext
|
||||||
|
let activeMastodonAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
|
||||||
|
let statusFetchedResultsController: StatusFetchedResultsController
|
||||||
|
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||||
|
|
||||||
|
// output
|
||||||
|
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||||
|
private(set) lazy var stateMachine: GKStateMachine = {
|
||||||
|
let stateMachine = GKStateMachine(states: [
|
||||||
|
State.Initial(viewModel: self),
|
||||||
|
State.Reloading(viewModel: self),
|
||||||
|
State.Fail(viewModel: self),
|
||||||
|
State.Idle(viewModel: self),
|
||||||
|
State.Loading(viewModel: self),
|
||||||
|
State.NoMore(viewModel: self),
|
||||||
|
])
|
||||||
|
stateMachine.enter(State.Initial.self)
|
||||||
|
return stateMachine
|
||||||
|
}()
|
||||||
|
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
|
||||||
|
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||||
|
managedObjectContext: context.managedObjectContext,
|
||||||
|
domain: nil,
|
||||||
|
additionalTweetPredicate: Status.notDeleted()
|
||||||
|
)
|
||||||
|
|
||||||
|
context.authenticationService.activeMastodonAuthenticationBox
|
||||||
|
.assign(to: \.value, on: activeMastodonAuthenticationBox)
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
activeMastodonAuthenticationBox
|
||||||
|
.map { $0?.domain }
|
||||||
|
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
statusFetchedResultsController.objectIDs
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] objectIDs in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||||
|
|
||||||
|
var items: [Item] = []
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
// not animate when empty items fix loader first appear layout issue
|
||||||
|
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
|
||||||
|
let oldSnapshot = diffableDataSource.snapshot()
|
||||||
|
for item in oldSnapshot.itemIdentifiers {
|
||||||
|
guard case let .status(objectID, attribute) = item else { continue }
|
||||||
|
oldSnapshotAttributeDict[objectID] = attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
for objectID in objectIDs {
|
||||||
|
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
|
||||||
|
items.append(.status(objectID: objectID, attribute: attribute))
|
||||||
|
}
|
||||||
|
snapshot.appendItems(items, toSection: .main)
|
||||||
|
|
||||||
|
if let currentState = self.stateMachine.currentState {
|
||||||
|
switch currentState {
|
||||||
|
case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
|
||||||
|
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||||
|
case is State.NoMore:
|
||||||
|
break
|
||||||
|
// TODO: handle other states
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -7,6 +7,11 @@
|
||||||
|
|
||||||
import os.log
|
import 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
//
|
||||||
|
// ProfileHeaderViewModel.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-9.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import Kanna
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
final class ProfileHeaderViewModel {
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// input
|
||||||
|
let context: AppContext
|
||||||
|
let isEditing = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
|
||||||
|
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
|
||||||
|
// output
|
||||||
|
let displayProfileInfo = ProfileInfo()
|
||||||
|
let editProfileInfo = ProfileInfo()
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
isEditing
|
||||||
|
.removeDuplicates() // only triiger when value toggle
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isEditing in
|
||||||
|
guard let self = self else { return }
|
||||||
|
// setup editing value when toggle to editing
|
||||||
|
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
|
||||||
|
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
|
||||||
|
self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProfileHeaderViewModel {
|
||||||
|
struct ProfileInfo {
|
||||||
|
let name = CurrentValueSubject<String?, Never>(nil)
|
||||||
|
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
|
||||||
|
let note = CurrentValueSubject<String?, Never>(nil)
|
||||||
|
|
||||||
|
enum ImageResource {
|
||||||
|
case url(URL?)
|
||||||
|
case image(UIImage?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProfileHeaderViewModel {
|
||||||
|
|
||||||
|
static func normalize(note: String?) -> String? {
|
||||||
|
guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = try? HTML(html: note, encoding: .utf8)
|
||||||
|
return html?.text
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if profile chagned or not
|
||||||
|
func isProfileInfoEdited() -> Bool {
|
||||||
|
guard isEditing.value else { return false }
|
||||||
|
|
||||||
|
guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true }
|
||||||
|
guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true }
|
||||||
|
guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true }
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfileInfo() -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
return Fail(error: APIService.APIError.implicit(.badRequest)).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
let domain = activeMastodonAuthenticationBox.domain
|
||||||
|
let authorization = activeMastodonAuthenticationBox.userAuthorization
|
||||||
|
|
||||||
|
let image: UIImage? = {
|
||||||
|
guard case let .image(_image) = editProfileInfo.avatarImageResource.value else { return nil }
|
||||||
|
guard let image = _image else { return nil }
|
||||||
|
guard image.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else {
|
||||||
|
return image.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel)
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}()
|
||||||
|
|
||||||
|
let query = Mastodon.API.Account.UpdateCredentialQuery(
|
||||||
|
discoverable: nil,
|
||||||
|
bot: nil,
|
||||||
|
displayName: editProfileInfo.name.value,
|
||||||
|
note: editProfileInfo.note.value,
|
||||||
|
avatar: image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) },
|
||||||
|
header: nil,
|
||||||
|
locked: nil,
|
||||||
|
source: nil,
|
||||||
|
fieldsAttributes: nil // TODO:
|
||||||
|
)
|
||||||
|
return context.apiService.accountUpdateCredentials(
|
||||||
|
domain: domain,
|
||||||
|
query: query,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -8,6 +8,7 @@
|
||||||
import os.log
|
import 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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
//
|
|
||||||
// ViewController.swift
|
|
||||||
// Mastodon
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-3-31.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class ViewController: UIViewController {
|
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
||||||
return .darkContent
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -14,78 +14,6 @@ extension Mastodon.API.Favorites {
|
||||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites")
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue