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

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

View File

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

View File

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

View File

@ -44,6 +44,8 @@
"sign_up": "Sign Up",
"see_more": "See More",
"preview": "Preview",
"share": "Share",
"share_user": "Share %s",
"open_in_safari": "Open in Safari"
},
"status": {
@ -69,9 +71,11 @@
"firendship": {
"follow": "Follow",
"following": "Following",
"request": "Request",
"pending": "Pending",
"block": "Block",
"block_user": "Block %s",
"block_domain": "Block %s",
"unblock": "Unblock",
"unblock_user": "Unblock %s",
"blocked": "Blocked",
@ -91,7 +95,8 @@
"no_status_found": "No Status Found",
"blocking_warning": "You cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.",
"blocked_warning": "You cant view Artbots profile\n until they unblock you.",
"suspended_warning": "This account is suspended."
"suspended_warning": "This account has been suspended.",
"user_suspended_warning": "%s's account has been suspended."
}
}
},
@ -217,7 +222,7 @@
"new_posts": "See new posts",
"published": "Published!",
"Publishing": "Publishing post..."
},
}
},
"public_timeline": {
"title": "Public"
@ -261,6 +266,7 @@
}
},
"profile": {
"subtitle": "%s posts",
"dashboard": {
"posts": "posts",
"following": "following",
@ -303,6 +309,9 @@
},
"hashtag": {
"prompt": "%s people talking"
},
"favorite": {
"title": "Your Favorites"
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,62 @@
//
// SafariActivity.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-8.
//
import UIKit
import SafariServices
final class SafariActivity: UIActivity {
weak var sceneCoordinator: SceneCoordinator?
var url: NSURL?
init(sceneCoordinator: SceneCoordinator) {
self.sceneCoordinator = sceneCoordinator
}
override var activityType: UIActivity.ActivityType? {
return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity")
}
override var activityTitle: String? {
return L10n.Common.Controls.Actions.openInSafari
}
override var activityImage: UIImage? {
return UIImage(systemName: "safari")
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
for item in activityItems {
guard let _ = item as? NSURL, sceneCoordinator != nil else { continue }
return true
}
return false
}
override func prepare(withActivityItems activityItems: [Any]) {
for item in activityItems {
guard let url = item as? NSURL else { continue }
self.url = url
}
}
override var activityViewController: UIViewController? {
return nil
}
override func perform() {
guard let url = url else {
activityDidFinish(false)
return
}
sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil))
activityDidFinish(true)
}
}

View File

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

View File

@ -63,11 +63,21 @@ extension Item {
let id = UUID()
let reason: Reason
enum Reason {
enum Reason: Equatable {
case noStatusFound
case blocking
case blocked
case suspended
case suspended(name: String?)
static func == (lhs: Item.EmptyStateHeaderAttribute.Reason, rhs: Item.EmptyStateHeaderAttribute.Reason) -> Bool {
switch (lhs, rhs) {
case (.noStatusFound, noStatusFound): return true
case (.blocking, blocking): return true
case (.blocked, blocked): return true
case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight
default: return false
}
}
}
init(reason: Reason) {

View File

@ -16,7 +16,8 @@ extension CategoryPickerSection {
for collectionView: UICollectionView,
dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem> {
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
guard let _ = dependency else { return nil }
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
switch item {
case .all:

View File

@ -17,7 +17,8 @@ extension CustomEmojiPickerSection {
for collectionView: UICollectionView,
dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> {
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
guard let _ = dependency else { return nil }
switch item {
case .emoji(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell

View File

@ -25,7 +25,13 @@ extension PickServerSection {
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
pickServerCellDelegate: PickServerCellDelegate
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerCategoriesCellDelegate, weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in
UITableViewDiffableDataSource(tableView: tableView) { [
weak dependency,
weak pickServerCategoriesCellDelegate,
weak pickServerSearchCellDelegate,
weak pickServerCellDelegate
] tableView, indexPath, item -> UITableViewCell? in
guard let dependency = dependency else { return nil }
switch item {
case .header:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell

View File

@ -28,6 +28,7 @@ extension MastodonUser.Property {
followersCount: entity.followersCount,
locked: entity.locked,
bot: entity.bot,
suspended: entity.suspended,
createdAt: entity.createdAt,
networkDate: networkDate
)
@ -62,3 +63,21 @@ extension MastodonUser {
}
}
extension MastodonUser {
var profileURL: URL {
if let urlString = self.url,
let url = URL(string: urlString) {
return url
} else {
return URL(string: "https://\(self.domain)/@\(username)")!
}
}
var activityItems: [Any] {
var items: [Any] = []
items.append(profileURL)
return items
}
}

View File

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

View File

@ -78,6 +78,12 @@ internal enum L10n {
internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto")
/// See More
internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore")
/// Share
internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share")
/// Share %@
internal static func shareUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Actions.ShareUser", String(describing: p1))
}
/// Sign In
internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn")
/// Sign Up
@ -90,6 +96,10 @@ internal enum L10n {
internal enum Firendship {
/// Block
internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block")
/// Block %@
internal static func blockDomain(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain", String(describing: p1))
}
/// Blocked
internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked")
/// Block %@
@ -112,6 +122,8 @@ internal enum L10n {
}
/// Pending
internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending")
/// Request
internal static let request = L10n.tr("Localizable", "Common.Controls.Firendship.Request")
/// Unblock
internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock")
/// Unblock %@
@ -179,8 +191,12 @@ internal enum L10n {
internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning")
/// No Status Found
internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound")
/// This account is suspended.
/// This account has been suspended.
internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning")
/// %@'s account has been suspended.
internal static func userSuspendedWarning(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1))
}
}
internal enum Loader {
/// Loading missing posts...
@ -299,6 +315,10 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title")
}
}
internal enum Favorite {
/// Your Favorites
internal static let title = L10n.tr("Localizable", "Scene.Favorite.Title")
}
internal enum Hashtag {
/// %@ people talking
internal static func prompt(_ p1: Any) -> String {
@ -320,6 +340,10 @@ internal enum L10n {
}
}
internal enum Profile {
/// %@ posts
internal static func subtitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Profile.Subtitle", String(describing: p1))
}
internal enum Dashboard {
/// followers
internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers")

View File

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

View File

@ -95,7 +95,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
videoPlayerViewModel.didEndDisplaying()
}
}
if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = status?.mediaAttachments?.contains(currentAudioAttachment) {
if let currentAudioAttachment = self.context.audioPlaybackService.attachment,
status?.mediaAttachments?.contains(currentAudioAttachment) == true {
self.context.audioPlaybackService.pause()
}
}

View File

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

View File

@ -0,0 +1,123 @@
//
// StatusTableViewControllerAspect.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-7.
//
import UIKit
import AVKit
// Check List Last Updated
// - FavoriteViewController: 2021/4/8
// - HashtagTimelineViewController: 2021/4/8
// - UserTimelineViewController: 2021/4/8
// * StatusTableViewControllerAspect: 2021/4/7
// (Fake) Aspect protocol to group common protocol extension implementations
// Needs update related view controller when aspect interface changes
/// Status related operations aspect
/// Please check the aspect methods (Option+Click) and add hook to implement features
/// - UI
/// - Media
/// - Data Source
protocol StatusTableViewControllerAspect: UIViewController {
var tableView: UITableView { get }
}
// MARK: - UIViewController [A]
// [A1] aspectViewWillAppear(_:)
extension StatusTableViewControllerAspect {
/// [UI] hook to deselect row in the transitioning for the table view
func aspectViewWillAppear(_ animated: Bool) {
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
}
extension StatusTableViewControllerAspect where Self: NeedsDependency {
/// [Media] hook to notify video service
func aspectViewDidDisappear(_ animated: Bool) {
context.videoPlaybackService.viewDidDisappear(from: self)
context.audioPlaybackService.viewDidDisappear(from: self)
}
}
// MARK: - UITableViewDelegate [B]
// [B1] aspectTableView(_:estimatedHeightForRowAt:)
extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer {
/// [Data Source] hook to notify table view bottom loader
func aspectScrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView)
}
}
// [B2] aspectTableView(_:estimatedHeightForRowAt:)
extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer {
/// [UI] hook to estimate table view cell height from cache
func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
handleTableView(tableView, estimatedHeightForRowAt: indexPath)
}
}
// [B3] aspectTableView(_:willDisplay:forRowAt:)
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
func aspectTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
}
// [B4] StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:)
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
/// [Media] hook to notify video service
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
}
extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer & StatusProvider {
/// [UI] hook to cache table view cell height
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
}
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer & StatusProvider {
/// [Media] hook to notify video service
/// [UI] hook to cache table view cell height
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
}
// MARK: - UITableViewDataSourcePrefetching [C]
// [C1] aspectTableView(:prefetchRowsAt)
extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider {
/// [Data Source] hook to prefetch reply to info for status
func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
handleTableView(tableView, prefetchRowsAt: indexPaths)
}
}
// MARK: - AVPlayerViewControllerDelegate & NeedsDependency [D]
// [D1] aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:)
extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency {
/// [Media] hook to mark transitioning to video service
func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// [D2] aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:)
extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency {
/// [Media] hook to mark transitioning to video service
func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}

View File

@ -7,6 +7,30 @@
import UIKit
protocol TableViewCellHeightCacheableContainer: UIViewController {
// TODO:
protocol TableViewCellHeightCacheableContainer: StatusProvider {
var cellFrameCache: NSCache<NSNumber, NSValue> { get }
}
extension TableViewCellHeightCacheableContainer {
func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let item = item(for: nil, indexPath: indexPath) else { return }
let key = item.hashValue
let frame = cell.frame
cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
}
func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let item = item(for: nil, indexPath: indexPath) else { return UITableView.automaticDimension }
guard let frame = cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
if case .bottomLoader = item {
return TimelineLoaderTableViewCell.cellHeight
} else {
return UITableView.automaticDimension
}
}
return ceil(frame.height)
}
}

View File

@ -139,7 +139,10 @@ extension UserProviderFacade {
for mastodonUser: MastodonUser,
isMuting: Bool,
isBlocking: Bool,
provider: UserProvider
needsShareAction: Bool,
provider: UserProvider,
sourceView: UIView?,
barButtonItem: UIBarButtonItem?
) -> UIMenu {
var children: [UIMenuElement] = []
let name = mastodonUser.displayNameWithFallback
@ -198,7 +201,32 @@ extension UserProviderFacade {
children.append(blockMenu)
}
if needsShareAction {
let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in
guard let provider = provider else { return }
let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: provider)
provider.coordinator.present(
scene: .activityViewController(
activityViewController: activityViewController,
sourceView: sourceView,
barButtonItem: barButtonItem
),
from: provider,
transition: .activityViewControllerPresent(animated: true, completion: nil)
)
}
children.append(shareAction)
}
return UIMenu(title: "", options: [], children: children)
}
static func createActivityViewControllerForMastodonUser(mastodonUser: MastodonUser, dependency: NeedsDependency) -> UIActivityViewController {
let activityViewController = UIActivityViewController(
activityItems: mastodonUser.activityItems,
applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)]
)
return activityViewController
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.200",
"blue" : "128",
"green" : "120",
"red" : "120"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.360",
"blue" : "128",
"green" : "120",
"red" : "120"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.360",
"blue" : "128",
"green" : "120",
"red" : "120"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

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

View File

@ -21,10 +21,18 @@ extension HomeTimelineViewController {
children: [
moveMenu,
dropMenu,
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showWelcomeAction(action)
},
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showPublicTimelineAction(action)
},
UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showProfileAction(action)
},
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return }
self.signOutAction(action)
@ -273,9 +281,28 @@ extension HomeTimelineViewController {
.store(in: &disposeBag)
}
@objc private func showWelcomeAction(_ sender: UIAction) {
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func showPublicTimelineAction(_ sender: UIAction) {
coordinator.present(scene: .publicTimeline, from: self, transition: .show)
}
@objc private func showProfileAction(_ sender: UIAction) {
let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert)
alertController.addTextField()
let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
guard let self = self else { return }
guard let textField = alertController?.textFields?.first else { return }
let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "")
self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
}
alertController.addAction(showAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
}
#endif

View File

@ -67,6 +67,17 @@ extension MastodonPickServerViewController {
setupOnboardingAppearance()
defer { setupNavigationBarBackgroundView() }
#if DEBUG
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil)
let children: [UIMenuElement] = [
UIAction(title: "Dismiss", image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
guard let self = self else { return }
self.dismiss(animated: true, completion: nil)
})
]
navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children)
#endif
view.addSubview(nextStepButton)
NSLayoutConstraint.activate([
nextStepButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: MastodonPickServerViewController.actionButtonMargin),

View File

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

View File

@ -42,7 +42,7 @@ extension MastodonRegisterViewController {
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
private func cropImage(image:UIImage,pickerViewController:UIViewController) {
private func cropImage(image: UIImage, pickerViewController: UIViewController) {
DispatchQueue.main.async {
let cropController = CropViewController(croppingStyle: .default, image: image)
cropController.delegate = self

View File

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

View File

@ -0,0 +1,87 @@
//
// FavoriteViewController+StatusProvider.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-7.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
// MARK: - StatusProvider
extension FavoriteViewController: StatusProvider {
func status() -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never> {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
}
switch item {
case .status(let objectID, _):
let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
managedObjectContext.perform {
let status = managedObjectContext.object(with: objectID) as? Status
promise(.success(status))
}
default:
promise(.success(nil))
}
}
}
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
var managedObjectContext: NSManagedObjectContext {
return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
}
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
return viewModel.diffableDataSource
}
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return nil
}
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
return nil
}
return item
}
func items(indexPaths: [IndexPath]) -> [Item] {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return []
}
var items: [Item] = []
for indexPath in indexPaths {
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
items.append(item)
}
return items
}
}

View File

@ -0,0 +1,153 @@
//
// FavoriteViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-6.
//
// Note: Prefer use US favorite then EN favourite in coding
// to following the text checker auto-correct behavior
import os.log
import UIKit
import AVKit
import Combine
import GameplayKit
final class FavoriteViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: FavoriteViewModel!
let titleView = DoubleTitleLabelNavigationBarTitleView()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension FavoriteViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
navigationItem.titleView = titleView
titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.delegate = self
tableView.prefetchDataSource = self
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
statusTableViewCellDelegate: self
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
aspectViewWillAppear(animated)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
aspectViewDidDisappear(animated)
}
}
// MARK: - StatusTableViewControllerAspect
extension FavoriteViewController: StatusTableViewControllerAspect { }
// MARK: - TableViewCellHeightCacheableContainer
extension FavoriteViewController: TableViewCellHeightCacheableContainer {
var cellFrameCache: NSCache<NSNumber, NSValue> {
return viewModel.cellFrameCache
}
}
// MARK: - UIScrollViewDelegate
extension FavoriteViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
aspectScrollViewDidScroll(scrollView)
}
}
// MARK: - UITableViewDelegate
extension FavoriteViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
}
// MARK: - UITableViewDataSourcePrefetching
extension FavoriteViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
aspectTableView(tableView, prefetchRowsAt: indexPaths)
}
}
// MARK: - AVPlayerViewControllerDelegate
extension FavoriteViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// MARK: - TimelinePostTableViewCellDelegate
extension FavoriteViewController: StatusTableViewCellDelegate {
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
func parent() -> UIViewController { return self }
}
// MARK: - LoadMoreConfigurableTableViewContainer
extension FavoriteViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = FavoriteViewModel.State.Loading
var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
}

View File

@ -0,0 +1,39 @@
//
// FavoriteViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-7.
//
import UIKit
extension FavoriteViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
statusTableViewCellDelegate: StatusTableViewCellDelegate
) {
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.share()
.eraseToAnyPublisher()
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil
)
// set empty section to make update animation top-to-bottom style
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
stateMachine.enter(State.Reloading.self)
}
}

View File

@ -0,0 +1,177 @@
//
// FavoriteViewModel+State.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-7.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension FavoriteViewModel {
class State: GKState {
weak var viewModel: FavoriteViewModel?
init(viewModel: FavoriteViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
}
}
}
extension FavoriteViewModel.State {
class Initial: FavoriteViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
switch stateClass {
case is Reloading.Type:
return viewModel.activeMastodonAuthenticationBox.value != nil
default:
return false
}
}
}
class Reloading: FavoriteViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
// reset
viewModel.statusFetchedResultsController.statusIDs.value = []
stateMachine.enter(Loading.self)
}
}
class Fail: FavoriteViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
stateMachine.enter(Loading.self)
}
}
}
class Idle: FavoriteViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type, is Loading.Type:
return true
default:
return false
}
}
}
class Loading: FavoriteViewModel.State {
var maxID: String?
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
case is NoMore.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
if previousState is Reloading {
maxID = nil
}
// prefer use `maxID` token in response header
// let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last
viewModel.context.apiService.favoritedStatuses(
maxID: maxID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
stateMachine.enter(Fail.self)
case .finished:
break
}
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
hasNewStatusesAppend = true
}
self.maxID = response.link?.maxID
let hasNextPage: Bool = {
guard let link = response.link else { return true } // assert has more when link invalid
return link.maxID != nil
}()
if hasNewStatusesAppend && hasNextPage {
stateMachine.enter(Idle.self)
} else {
stateMachine.enter(NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
}
.store(in: &viewModel.disposeBag)
}
}
class NoMore: FavoriteViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
}
}
}

View File

@ -0,0 +1,101 @@
//
// FavoriteViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-6.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
import GameplayKit
final class FavoriteViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let activeMastodonAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
let statusFetchedResultsController: StatusFetchedResultsController
let cellFrameCache = NSCache<NSNumber, NSValue>()
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
private(set) lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Reloading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.Loading(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
init(context: AppContext) {
self.context = context
self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalTweetPredicate: Status.notDeleted()
)
context.authenticationService.activeMastodonAuthenticationBox
.assign(to: \.value, on: activeMastodonAuthenticationBox)
.store(in: &disposeBag)
activeMastodonAuthenticationBox
.map { $0?.domain }
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
statusFetchedResultsController.objectIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] objectIDs in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var items: [Item] = []
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
defer {
// not animate when empty items fix loader first appear layout issue
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
}
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
let oldSnapshot = diffableDataSource.snapshot()
for item in oldSnapshot.itemIdentifiers {
guard case let .status(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
}
for objectID in objectIDs {
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
items.append(.status(objectID: objectID, attribute: attribute))
}
snapshot.appendItems(items, toSection: .main)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
case is State.NoMore:
break
// TODO: handle other states
default:
break
}
}
}
.store(in: &disposeBag)
}
}

View File

@ -7,6 +7,11 @@
import os.log
import UIKit
import Combine
import PhotosUI
import AlamofireImage
import CropViewController
import TwitterTextEditor
protocol ProfileHeaderViewControllerDelegate: class {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
@ -19,8 +24,21 @@ final class ProfileHeaderViewController: UIViewController {
static let segmentedControlMarginHeight: CGFloat = 20
static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight
var disposeBag = Set<AnyCancellable>()
weak var delegate: ProfileHeaderViewControllerDelegate?
var viewModel: ProfileHeaderViewModel!
let titleView: DoubleTitleLabelNavigationBarTitleView = {
let titleView = DoubleTitleLabelNavigationBarTitleView()
titleView.titleLabel.textColor = .white
titleView.titleLabel.alpha = 0
titleView.subtitleLabel.textColor = .white
titleView.subtitleLabel.alpha = 0
titleView.layer.masksToBounds = true
return titleView
}()
let profileHeaderView = ProfileHeaderView()
let pageSegmentedControl: UISegmentedControl = {
let segmenetedControl = UISegmentedControl(items: ["A", "B"])
@ -34,6 +52,28 @@ final class ProfileHeaderViewController: UIViewController {
// private var isAdjustBannerImageViewForSafeAreaInset = false
private var containerSafeAreaInset: UIEdgeInsets = .zero
private(set) lazy var imagePicker: PHPickerViewController = {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 1
let imagePicker = PHPickerViewController(configuration: configuration)
imagePicker.delegate = self
return imagePicker
}()
private(set) lazy var imagePickerController: UIImagePickerController = {
let imagePickerController = UIImagePickerController()
imagePickerController.sourceType = .camera
imagePickerController.delegate = self
return imagePickerController
}()
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
documentPickerController.delegate = self
return documentPickerController
}()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
@ -67,11 +107,99 @@ extension ProfileHeaderViewController {
])
pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged)
Publishers.CombineLatest(
viewModel.viewDidAppear.eraseToAnyPublisher(),
viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSetted in
guard let self = self else { return }
self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0
self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0
}
.store(in: &disposeBag)
viewModel.needsSetupBottomShadow
.receive(on: DispatchQueue.main)
.sink { [weak self] needsSetupBottomShadow in
guard let self = self else { return }
self.setupBottomShadow()
}
.store(in: &disposeBag)
Publishers.CombineLatest4(
viewModel.isEditing.eraseToAnyPublisher(),
viewModel.displayProfileInfo.avatarImageResource.eraseToAnyPublisher(),
viewModel.editProfileInfo.avatarImageResource.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, resource, editingResource, _ in
guard let self = self else { return }
let url: URL? = {
guard case let .url(url) = resource else { return nil }
return url
}()
let image: UIImage? = {
guard case let .image(image) = editingResource else { return nil }
return image
}()
self.profileHeaderView.configure(
with: AvatarConfigurableViewConfiguration(
avatarImageURL: image == nil ? url : nil, // set only when image empty
placeholderImage: image,
borderColor: .white,
borderWidth: 2
)
)
}
.store(in: &disposeBag)
Publishers.CombineLatest3(
viewModel.isEditing.eraseToAnyPublisher(),
viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(),
viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, name, editingName in
guard let self = self else { return }
self.profileHeaderView.nameTextField.text = isEditing ? editingName : name
}
.store(in: &disposeBag)
Publishers.CombineLatest3(
viewModel.isEditing.eraseToAnyPublisher(),
viewModel.displayProfileInfo.note.removeDuplicates().eraseToAnyPublisher(),
viewModel.editProfileInfo.note.removeDuplicates().eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, note, editingNote in
guard let self = self else { return }
self.profileHeaderView.bioActiveLabel.configure(note: note ?? "")
self.profileHeaderView.bioTextEditorView.text = editingNote ?? ""
}
.store(in: &disposeBag)
profileHeaderView.bioTextEditorView.changeObserver = self
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
guard let self = self else { return }
guard let textField = notification.object as? UITextField else { return }
self.viewModel.editProfileInfo.name.value = textField.text
}
.store(in: &disposeBag)
profileHeaderView.editAvatarButton.menu = createAvatarContextMenu()
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true
// Deprecated:
// not needs this tweak due to force layout update in the parent
// if !isAdjustBannerImageViewForSafeAreaInset {
@ -85,11 +213,52 @@ extension ProfileHeaderViewController {
super.viewDidLayoutSubviews()
delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view)
view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero)
setupBottomShadow()
}
}
extension ProfileHeaderViewController {
private func createAvatarContextMenu() -> UIMenu {
var children: [UIMenuElement] = []
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function)
self.present(self.imagePicker, animated: true, completion: nil)
}
children.append(photoLibraryAction)
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function)
self.present(self.imagePickerController, animated: true, completion: nil)
})
children.append(cameraAction)
}
let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function)
self.present(self.documentPickerController, animated: true, completion: nil)
}
children.append(browseAction)
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
private func cropImage(image: UIImage, pickerViewController: UIViewController) {
DispatchQueue.main.async {
let cropController = CropViewController(croppingStyle: .default, image: image)
cropController.delegate = self
cropController.setAspectRatioPreset(.presetSquare, animated: true)
cropController.aspectRatioPickerButtonHidden = true
cropController.aspectRatioLockEnabled = true
pickerViewController.dismiss(animated: true, completion: {
self.present(cropController, animated: true, completion: nil)
})
}
}
}
extension ProfileHeaderViewController {
@objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) {
@ -105,6 +274,15 @@ extension ProfileHeaderViewController {
containerSafeAreaInset = inset
}
func setupBottomShadow() {
guard viewModel.needsSetupBottomShadow.value else {
view.layer.shadowColor = nil
view.layer.shadowRadius = 0
return
}
view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero)
}
private func updateHeaderBottomShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1)
if bottomShadowAlpha != alpha {
@ -126,19 +304,132 @@ extension ProfileHeaderViewController {
let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil)
let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height
// scroll from bottom to top: 1 -> 2 -> 3
if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {
// 1
// banner top pin to window top and expand
bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y
bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height
} else if bannerContainerBottomOffset < containerSafeAreaInset.top {
// 3
// banner bottom pin to navigation bar bottom and
// the `progress` growth to 1 then segemented control pin to top
bannerImageView.frame.origin.y = -containerSafeAreaInset.top
let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset)
bannerImageView.frame.size.height = bannerImageHeight
} else {
// 2
// banner move with scrolling from bottom to top until the
// banner bottom higher than navigation bar bottom
bannerImageView.frame.origin.y = -containerSafeAreaInset.top
bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top
}
// TODO: handle titleView
// set title view offset
let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y
let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset
titleView.containerView.transform = CGAffineTransform(translationX: 0, y: max(0, titleViewContentOffset))
if viewModel.viewDidAppear.value {
viewModel.isTitleViewContentOffsetSet.value = true
}
// set avatar
if progress > 0 {
setProfileBannerFade(alpha: 0)
} else if progress > -0.3 {
// y = -(10/3)x
let alpha = -10.0 / 3.0 * progress
setProfileBannerFade(alpha: alpha)
} else {
setProfileBannerFade(alpha: 1)
}
}
private func setProfileBannerFade(alpha: CGFloat) {
profileHeaderView.avatarImageView.alpha = alpha
profileHeaderView.editAvatarBackgroundView.alpha = alpha
profileHeaderView.nameTextFieldBackgroundView.alpha = alpha
profileHeaderView.nameTextField.alpha = alpha
profileHeaderView.usernameLabel.alpha = alpha
}
}
// MARK: - TextEditorViewChangeObserver
extension ProfileHeaderViewController: TextEditorViewChangeObserver {
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text)
guard changeResult.isTextChanged else { return }
assert(textEditorView === profileHeaderView.bioTextEditorView)
viewModel.editProfileInfo.note.value = textEditorView.text
}
}
// MARK: - PHPickerViewControllerDelegate
extension ProfileHeaderViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true, completion: nil)
guard let result = results.first else { return }
PHPickerResultLoader.loadImageData(from: result)
.sink { [weak self] completion in
guard let _ = self else { return }
switch completion {
case .failure:
// TODO: handle error
break
case .finished:
break
}
} receiveValue: { [weak self] imageData in
guard let self = self else { return }
guard let imageData = imageData else { return }
guard let image = UIImage(data: imageData) else { return }
self.cropImage(image: image, pickerViewController: picker)
}
.store(in: &disposeBag)
}
}
// MARK: - UIImagePickerControllerDelegate
extension ProfileHeaderViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
picker.dismiss(animated: true, completion: nil)
guard let image = info[.originalImage] as? UIImage else { return }
cropImage(image: image, pickerViewController: picker)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
picker.dismiss(animated: true, completion: nil)
}
}
// MARK: - UIDocumentPickerDelegate
extension ProfileHeaderViewController: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
do {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
let imageData = try Data(contentsOf: url)
guard let image = UIImage(data: imageData) else { return }
cropImage(image: image, pickerViewController: controller)
} catch {
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
}
}
}
// MARK: - CropViewControllerDelegate
extension ProfileHeaderViewController: CropViewControllerDelegate {
public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) {
viewModel.editProfileInfo.avatarImageResource.value = .image(image)
cropViewController.dismiss(animated: true, completion: nil)
}
}

View File

@ -0,0 +1,115 @@
//
// ProfileHeaderViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-9.
//
import UIKit
import Combine
import Kanna
import MastodonSDK
final class ProfileHeaderViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let isEditing = CurrentValueSubject<Bool, Never>(false)
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false)
// output
let displayProfileInfo = ProfileInfo()
let editProfileInfo = ProfileInfo()
init(context: AppContext) {
self.context = context
isEditing
.removeDuplicates() // only triiger when value toggle
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing in
guard let self = self else { return }
// setup editing value when toggle to editing
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value)
}
.store(in: &disposeBag)
}
}
extension ProfileHeaderViewModel {
struct ProfileInfo {
let name = CurrentValueSubject<String?, Never>(nil)
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
let note = CurrentValueSubject<String?, Never>(nil)
enum ImageResource {
case url(URL?)
case image(UIImage?)
}
}
}
extension ProfileHeaderViewModel {
static func normalize(note: String?) -> String? {
guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else {
return nil
}
let html = try? HTML(html: note, encoding: .utf8)
return html?.text
}
// check if profile chagned or not
func isProfileInfoEdited() -> Bool {
guard isEditing.value else { return false }
guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true }
guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true }
guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true }
return false
}
func updateProfileInfo() -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return Fail(error: APIService.APIError.implicit(.badRequest)).eraseToAnyPublisher()
}
let domain = activeMastodonAuthenticationBox.domain
let authorization = activeMastodonAuthenticationBox.userAuthorization
let image: UIImage? = {
guard case let .image(_image) = editProfileInfo.avatarImageResource.value else { return nil }
guard let image = _image else { return nil }
guard image.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else {
return image.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel)
}
return image
}()
let query = Mastodon.API.Account.UpdateCredentialQuery(
discoverable: nil,
bot: nil,
displayName: editProfileInfo.name.value,
note: editProfileInfo.note.value,
avatar: image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) },
header: nil,
locked: nil,
source: nil,
fieldsAttributes: nil // TODO:
)
return context.apiService.accountUpdateCredentials(
domain: domain,
query: query,
authorization: authorization
)
}
}

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import ActiveLabel
import TwitterTextEditor
protocol ProfileHeaderViewDelegate: class {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
@ -25,8 +26,13 @@ final class ProfileHeaderView: UIView {
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
static let bannerImageViewPlaceholderColor = UIColor.systemGray
static let bannerImageViewOverlayViewBackgroundNormalColor = UIColor.black.withAlphaComponent(0.5)
static let bannerImageViewOverlayViewBackgroundEditingColor = UIColor.black.withAlphaComponent(0.8)
weak var delegate: ProfileHeaderViewDelegate?
var state: State?
let bannerContainerView = UIView()
let bannerImageView: UIImageView = {
let imageView = UIImageView()
@ -41,7 +47,7 @@ final class ProfileHeaderView: UIView {
}()
let bannerImageViewOverlayView: UIView = {
let overlayView = UIView()
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
return overlayView
}()
@ -54,15 +60,39 @@ final class ProfileHeaderView: UIView {
return imageView
}()
let nameLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.textColor = .white
label.text = "Alice"
label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
return label
let editAvatarBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.black.withAlphaComponent(0.6)
view.layer.masksToBounds = true
view.layer.cornerCurve = .continuous
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
return view
}()
let editAvatarButton: HighlightDimmableButton = {
let button = HighlightDimmableButton()
button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal)
button.tintColor = .white
return button
}()
let nameTextFieldBackgroundView: UIView = {
let view = UIView()
view.layer.masksToBounds = true
view.layer.cornerCurve = .continuous
view.layer.cornerRadius = 10
return view
}()
let nameTextField: UITextField = {
let textField = UITextField()
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
textField.textColor = .white
textField.text = "Alice"
textField.autocorrectionType = .no
textField.autocapitalizationType = .none
textField.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
return textField
}()
let usernameLabel: UILabel = {
@ -84,9 +114,29 @@ final class ProfileHeaderView: UIView {
}()
let bioContainerView = UIView()
let bioContainerStackView = UIStackView()
let fieldContainerStackView = UIStackView()
let bioActiveLabelContainer: UIView = {
// use to set margin for active label
// the display/edit mode bio transition animation should without flicker with that
let view = UIView()
// note: comment out to see how it works
view.layoutMargins = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) // magic from TextEditorView
return view
}()
let bioActiveLabel = ActiveLabel(style: .default)
let bioTextEditorView: TextEditorView = {
let textEditorView = TextEditorView()
textEditorView.scrollView.isScrollEnabled = false
textEditorView.isScrollEnabled = false
textEditorView.font = .preferredFont(forTextStyle: .body)
textEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color
textEditorView.layer.masksToBounds = true
textEditorView.layer.cornerCurve = .continuous
textEditorView.layer.cornerRadius = 10
return textEditorView
}()
override init(frame: CGRect) {
super.init(frame: frame)
@ -138,11 +188,31 @@ extension ProfileHeaderView {
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
])
// name container: [display name | username]
editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.addSubview(editAvatarBackgroundView)
NSLayoutConstraint.activate([
editAvatarBackgroundView.topAnchor.constraint(equalTo: avatarImageView.topAnchor),
editAvatarBackgroundView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor),
editAvatarBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
])
editAvatarButton.translatesAutoresizingMaskIntoConstraints = false
editAvatarBackgroundView.addSubview(editAvatarButton)
NSLayoutConstraint.activate([
editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor),
editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor),
editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor),
editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor),
])
editAvatarBackgroundView.isUserInteractionEnabled = true
avatarImageView.isUserInteractionEnabled = true
// name container: [display name container | username]
let nameContainerStackView = UIStackView()
nameContainerStackView.preservesSuperviewLayoutMargins = true
nameContainerStackView.axis = .vertical
nameContainerStackView.spacing = 0
nameContainerStackView.spacing = 7
nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(nameContainerStackView)
NSLayoutConstraint.activate([
@ -150,7 +220,27 @@ extension ProfileHeaderView {
nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor),
])
nameContainerStackView.addArrangedSubview(nameLabel)
let displayNameStackView = UIStackView()
displayNameStackView.axis = .horizontal
nameTextField.translatesAutoresizingMaskIntoConstraints = false
displayNameStackView.addArrangedSubview(nameTextField)
NSLayoutConstraint.activate([
nameTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
nameTextField.setContentHuggingPriority(.defaultHigh, for: .horizontal)
nameTextFieldBackgroundView.translatesAutoresizingMaskIntoConstraints = false
displayNameStackView.addSubview(nameTextFieldBackgroundView)
NSLayoutConstraint.activate([
nameTextField.topAnchor.constraint(equalTo: nameTextFieldBackgroundView.topAnchor, constant: 5),
nameTextField.leadingAnchor.constraint(equalTo: nameTextFieldBackgroundView.leadingAnchor, constant: 5),
nameTextFieldBackgroundView.bottomAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 5),
nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor, constant: 5),
])
displayNameStackView.bringSubviewToFront(nameTextField)
displayNameStackView.addArrangedSubview(UIView())
nameContainerStackView.addArrangedSubview(displayNameStackView)
nameContainerStackView.addArrangedSubview(usernameLabel)
// meta container: [dashboard container | bio container | field container]
@ -192,15 +282,29 @@ extension ProfileHeaderView {
bioContainerView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addArrangedSubview(bioContainerView)
bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false
bioContainerView.addSubview(bioActiveLabel)
bioContainerStackView.translatesAutoresizingMaskIntoConstraints = false
bioContainerView.addSubview(bioContainerStackView)
NSLayoutConstraint.activate([
bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor),
bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor),
bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor),
bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor),
bioContainerStackView.topAnchor.constraint(equalTo: bioContainerView.topAnchor),
bioContainerStackView.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor),
bioContainerStackView.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor),
bioContainerStackView.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor),
])
bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false
bioActiveLabelContainer.addSubview(bioActiveLabel)
NSLayoutConstraint.activate([
bioActiveLabel.topAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.topAnchor),
bioActiveLabel.leadingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.leadingAnchor),
bioActiveLabel.trailingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.trailingAnchor),
bioActiveLabel.bottomAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.bottomAnchor),
])
bioContainerStackView.axis = .vertical
bioContainerStackView.addArrangedSubview(bioActiveLabelContainer)
bioContainerStackView.addArrangedSubview(bioTextEditorView)
fieldContainerStackView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addSubview(fieldContainerStackView)
@ -210,10 +314,58 @@ extension ProfileHeaderView {
bioActiveLabel.delegate = self
relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside)
configure(state: .normal)
}
}
extension ProfileHeaderView {
enum State {
case normal
case editing
}
func configure(state: State) {
guard self.state != state else { return } // avoid redundant animation
self.state = state
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
switch state {
case .normal:
nameTextField.isEnabled = false
bioActiveLabelContainer.isHidden = false
bioTextEditorView.isHidden = true
animator.addAnimations {
self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
self.nameTextFieldBackgroundView.backgroundColor = .clear
self.editAvatarBackgroundView.alpha = 0
}
animator.addCompletion { _ in
self.editAvatarBackgroundView.isHidden = true
}
case .editing:
nameTextField.isEnabled = true
bioActiveLabelContainer.isHidden = true
bioTextEditorView.isHidden = false
editAvatarBackgroundView.isHidden = false
editAvatarBackgroundView.alpha = 0
bioTextEditorView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor
self.nameTextFieldBackgroundView.backgroundColor = Asset.Profile.Banner.nameEditBackgroundGray.color
self.editAvatarBackgroundView.alpha = 1
self.bioTextEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color
}
}
animator.startAnimation()
}
}
extension ProfileHeaderView {
@objc private func relationshipActionButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)

View File

@ -9,6 +9,12 @@ import UIKit
final class ProfileRelationshipActionButton: RoundedEdgesButton {
let actvityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.color = .white
return activityIndicatorView
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
@ -23,7 +29,15 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton {
extension ProfileRelationshipActionButton {
private func _init() {
// do nothing
actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(actvityIndicatorView)
NSLayoutConstraint.activate([
actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
actvityIndicatorView.hidesWhenStopped = true
actvityIndicatorView.stopAnimating()
}
}
@ -34,10 +48,15 @@ extension ProfileRelationshipActionButton {
setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .disabled)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked {
actvityIndicatorView.stopAnimating()
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended {
isEnabled = false
} else if actionOptionSet.contains(.updating) {
isEnabled = false
actvityIndicatorView.startAnimating()
} else {
isEnabled = true
}

View File

@ -22,7 +22,7 @@ final class MeProfileViewModel: ProfileViewModel {
self.currentMastodonUser
.sink { [weak self] currentMastodonUser in
os_log("%{public}s[%{public}ld], %{public}s: current active twitter user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "<nil>")
os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "<nil>")
guard let self = self else { return }
self.mastodonUser.value = currentMastodonUser

View File

@ -18,6 +18,30 @@ final class ProfileViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
var viewModel: ProfileViewModel!
private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:)))
barButtonItem.tintColor = .white
return barButtonItem
}()
private(set) lazy var settingBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:)))
barButtonItem.tintColor = .white
return barButtonItem
}()
private(set) lazy var shareBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(ProfileViewController.shareBarButtonItemPressed(_:)))
barButtonItem.tintColor = .white
return barButtonItem
}()
private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "star"), style: .plain, target: self, action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:)))
barButtonItem.tintColor = .white
return barButtonItem
}()
private(set) lazy var replyBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:)))
barButtonItem.tintColor = .white
@ -54,12 +78,20 @@ final class ProfileViewController: UIViewController, NeedsDependency {
}()
private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController()
private(set) lazy var profileHeaderViewController = ProfileHeaderViewController()
private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = {
let viewController = ProfileHeaderViewController()
viewController.viewModel = ProfileHeaderViewModel(context: context)
return viewController
}()
private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint!
private var contentOffsets: [Int: CGFloat] = [:]
var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation?
// title view nested in header
var titleView: DoubleTitleLabelNavigationBarTitleView {
profileHeaderViewController.titleView
}
deinit {
os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function)
@ -116,27 +148,66 @@ extension ProfileViewController {
navigationItem.compactAppearance = barAppearance
navigationItem.scrollEdgeAppearance = barAppearance
navigationItem.titleView = UIView()
navigationItem.titleView = titleView
Publishers.CombineLatest(
let editingAndUpdatingPublisher = Publishers.CombineLatest(
viewModel.isEditing.eraseToAnyPublisher(),
viewModel.isUpdating.eraseToAnyPublisher()
)
// note: not add .share() here
let barButtonItemHiddenPublisher = Publishers.CombineLatest3(
viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(),
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
)
editingAndUpdatingPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
.sink { [weak self] isEditing, isUpdating in
guard let self = self else { return }
self.cancelEditingBarButtonItem.isEnabled = !isUpdating
}
.store(in: &disposeBag)
Publishers.CombineLatest3 (
viewModel.suspended.eraseToAnyPublisher(),
editingAndUpdatingPublisher.eraseToAnyPublisher(),
barButtonItemHiddenPublisher.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] suspended, tuple1, tuple2 in
guard let self = self else { return }
let (isEditing, _) = tuple1
let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2
var items: [UIBarButtonItem] = []
defer {
self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil
}
guard !suspended else {
return
}
guard !isEditing else {
items.append(self.cancelEditingBarButtonItem)
return
}
guard isMeBarButtonItemsHidden else {
items.append(self.settingBarButtonItem)
items.append(self.shareBarButtonItem)
items.append(self.favoriteBarButtonItem)
return
}
if !isReplyBarButtonItemHidden {
items.append(self.replyBarButtonItem)
}
if !isMoreMenuBarButtonItemHidden {
items.append(self.moreMenuBarButtonItem)
}
guard !items.isEmpty else {
self.navigationItem.rightBarButtonItems = nil
return
}
self.navigationItem.rightBarButtonItems = items
}
.store(in: &disposeBag)
@ -225,6 +296,23 @@ extension ProfileViewController {
profileSegmentedViewController.pagingViewController.pagingDelegate = self
// bind view model
Publishers.CombineLatest(
viewModel.name.eraseToAnyPublisher(),
viewModel.statusesCount.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] name, statusesCount in
guard let self = self else { return }
guard let title = name, let statusesCount = statusesCount,
let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else {
self.titleView.isHidden = true
return
}
let subtitle = L10n.Scene.Profile.subtitle(formattedStatusCount)
self.titleView.update(title: title, subtitle: subtitle)
self.titleView.isHidden = false
}
.store(in: &disposeBag)
viewModel.name
.receive(on: DispatchQueue.main)
.sink { [weak self] name in
@ -263,22 +351,15 @@ extension ProfileViewController {
)
}
.store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.avatarImageURL.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
)
viewModel.avatarImageURL
.receive(on: DispatchQueue.main)
.sink { [weak self] avatarImageURL, _ in
guard let self = self else { return }
self.profileHeaderViewController.profileHeaderView.configure(
with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2)
)
}
.map { url in ProfileHeaderViewModel.ProfileInfo.ImageResource.url(url) }
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.avatarImageResource)
.store(in: &disposeBag)
viewModel.name
.map { $0 ?? " " }
.map { $0 ?? "" }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel)
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
.store(in: &disposeBag)
viewModel.username
.map { username in username.flatMap { "@" + $0 } ?? " " }
@ -295,7 +376,8 @@ extension ProfileViewController {
}
let isMuting = relationshipActionOptionSet.contains(.muting)
let isBlocking = relationshipActionOptionSet.contains(.blocking)
self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self)
let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value
self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, needsShareAction: needsShareAction, provider: self, sourceView: nil, barButtonItem: self.moreMenuBarButtonItem)
}
.store(in: &disposeBag)
viewModel.isRelationshipActionButtonHidden
@ -305,27 +387,60 @@ extension ProfileViewController {
self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden
}
.store(in: &disposeBag)
Publishers.CombineLatest(
Publishers.CombineLatest3(
viewModel.relationshipActionOptionSet.eraseToAnyPublisher(),
viewModel.isEditing.eraseToAnyPublisher()
viewModel.isEditing.eraseToAnyPublisher(),
viewModel.isUpdating.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] relationshipActionSet, isEditing in
.sink { [weak self] relationshipActionSet, isEditing, isUpdating in
guard let self = self else { return }
let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton
if relationshipActionSet.contains(.edit) {
friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit)
// check .edit state and set .editing when isEditing
friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit))
self.profileHeaderViewController.profileHeaderView.configure(state: isUpdating || isEditing ? .editing : .normal)
} else {
friendshipButton.configure(actionOptionSet: relationshipActionSet)
}
}
.store(in: &disposeBag)
viewModel.isEditing
.handleEvents(receiveOutput: { [weak self] isEditing in
guard let self = self else { return }
// dismiss keyboard if needs
if !isEditing { self.view.endEditing(true) }
self.profileHeaderViewController.pageSegmentedControl.isEnabled = !isEditing
self.profileSegmentedViewController.view.isUserInteractionEnabled = !isEditing
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
animator.addAnimations {
self.profileSegmentedViewController.view.alpha = isEditing ? 0.2 : 1.0
self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0
}
animator.startAnimation()
})
.assign(to: \.value, on: profileHeaderViewController.viewModel.isEditing)
.store(in: &disposeBag)
Publishers.CombineLatest3(
viewModel.isBlocking.eraseToAnyPublisher(),
viewModel.isBlockedBy.eraseToAnyPublisher(),
viewModel.suspended.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isBlocking, isBlockedBy, suspended in
guard let self = self else { return }
let isNeedSetHidden = isBlocking || isBlockedBy || suspended
self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden
self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden
self.profileHeaderViewController.pageSegmentedControl.isHidden = isNeedSetHidden
self.viewModel.needsPagePinToTop.value = isNeedSetHidden
}
.store(in: &disposeBag)
viewModel.bioDescription
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] bio in
guard let self = self else { return }
self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "")
})
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.note)
.store(in: &disposeBag)
viewModel.statusesCount
.sink { [weak self] count in
@ -374,6 +489,7 @@ extension ProfileViewController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
currentPostTimelineTableViewContentSizeObservation = nil
}
@ -386,12 +502,45 @@ extension ProfileViewController {
viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag)
viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag)
viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag)
viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag)
viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag)
}
}
extension ProfileViewController {
@objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
viewModel.isEditing.value = false
}
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
@objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let mastodonUser = viewModel.mastodonUser.value else { return }
let activityViewController = UserProviderFacade.createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: self)
coordinator.present(
scene: .activityViewController(
activityViewController: activityViewController,
sourceView: nil,
barButtonItem: sender
),
from: self,
transition: .activityViewControllerPresent(animated: true, completion: nil)
)
}
@objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let favoriteViewModel = FavoriteViewModel(context: context)
coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show)
}
@objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let mastodonUser = viewModel.mastodonUser.value else { return }
@ -415,11 +564,6 @@ extension ProfileViewController {
}
}
// @objc private func avatarButtonPressed(_ sender: UIButton) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController))
// }
}
// MARK: - UIScrollViewDelegate
@ -436,12 +580,17 @@ extension ProfileViewController: UIScrollViewDelegate {
contentOffsets.removeAll()
} else {
containerScrollView.contentOffset.y = topMaxContentOffsetY
if viewModel.needsPagePinToTop.value {
// do nothing
} else {
if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer {
let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y
customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY
}
}
}
// elastically banner image
let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress)
@ -492,22 +641,42 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
let relationshipActionSet = viewModel.relationshipActionOptionSet.value
if relationshipActionSet.contains(.edit) {
guard !viewModel.isUpdating.value else { return }
if profileHeaderViewController.viewModel.isProfileInfoEdited() {
viewModel.isUpdating.value = true
profileHeaderViewController.viewModel.updateProfileInfo()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info success", ((#file as NSString).lastPathComponent), #line, #function)
}
self.viewModel.isUpdating.value = false
} receiveValue: { [weak self] _ in
guard let self = self else { return }
self.viewModel.isEditing.value = false
}
.store(in: &disposeBag)
} else {
viewModel.isEditing.value.toggle()
}
} else {
guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return }
switch relationshipAction {
case .none:
break
case .follow, .following:
case .follow, .reqeust, .pending, .following:
UserProviderFacade.toggleUserFollowRelationship(provider: self)
.sink { _ in
// TODO: handle error
} receiveValue: { _ in
// do nothing
}
.store(in: &disposeBag)
case .pending:
break
case .muting:
guard let mastodonUser = viewModel.mastodonUser.value else { return }
let name = mastodonUser.displayNameWithFallback
@ -557,9 +726,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
default:
assertionFailure()
}
}
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
//
// HashtagTimelineTitleView.swift
// DoubleTitleLabelNavigationBarTitleView.swift
// Mastodon
//
// Created by BradGao on 2021/4/1.
@ -7,7 +7,7 @@
import UIKit
final class HashtagTimelineNavigationBarTitleView: UIView {
final class DoubleTitleLabelNavigationBarTitleView: UIView {
let containerView = UIStackView()
@ -40,7 +40,7 @@ final class HashtagTimelineNavigationBarTitleView: UIView {
}
extension HashtagTimelineNavigationBarTitleView {
extension DoubleTitleLabelNavigationBarTitleView {
private func _init() {
containerView.axis = .vertical
containerView.alignment = .center
@ -58,10 +58,10 @@ extension HashtagTimelineNavigationBarTitleView {
containerView.addArrangedSubview(subtitleLabel)
}
func updateTitle(hashtag: String, peopleNumber: String?) {
titleLabel.text = "#\(hashtag)"
if let peopleNumebr = peopleNumber {
subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr)
func update(title: String, subtitle: String?) {
titleLabel.text = title
if let subtitle = subtitle {
subtitleLabel.text = subtitle
subtitleLabel.isHidden = false
} else {
subtitleLabel.text = nil
@ -69,3 +69,21 @@ extension HashtagTimelineNavigationBarTitleView {
}
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct DoubleTitleLabelNavigationBarTitleView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
DoubleTitleLabelNavigationBarTitleView()
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

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

View File

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

View File

@ -158,6 +158,7 @@ extension APIService {
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
return Mastodon.API.Account.follow(
session: session,
@ -166,22 +167,50 @@ extension APIService {
followQueryType: followQueryType,
authorization: authorization
)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let _ = self else { return }
switch completion {
// .handleEvents(receiveCompletion: { [weak self] completion in
// guard let _ = self else { return }
// switch completion {
// case .failure(let error):
// // TODO: handle error
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
// break
// case .finished:
// switch followQueryType {
// case .follow:
// break
// case .unfollow:
// break
// }
// }
// })
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
let requestMastodonUserRequest = MastodonUser.sortedFetchRequest
requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
requestMastodonUserRequest.fetchLimit = 1
guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return }
let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest
lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID)
lookUpMastodonUserRequest.fetchLimit = 1
let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first
if let lookUpMastodonuser = lookUpMastodonuser {
let entity = response.value
APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate)
}
}
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in
switch result {
case .success:
return response
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
break
case .finished:
switch followQueryType {
case .follow:
break
case .unfollow:
break
throw error
}
}
})
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}

View File

@ -95,6 +95,9 @@ extension APIService.CoreData {
user.update(statusesCount: property.statusesCount)
user.update(followingCount: property.followingCount)
user.update(followersCount: property.followersCount)
user.update(locked: property.locked)
property.bot.flatMap { user.update(bot: $0) }
property.suspended.flatMap { user.update(suspended: $0) }
user.didUpdate(at: networkDate)
}

View File

@ -234,8 +234,8 @@ extension APIService.Persist {
let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in
return next.statusProcessType == .create ? result + 1 : result
})
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge)
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: status: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge)
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
}
#endif
}

View File

@ -1,15 +0,0 @@
//
// ViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-31.
//
import UIKit
class ViewController: UIViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
}

View File

@ -14,78 +14,6 @@ extension Mastodon.API.Favorites {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites")
}
static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL {
let pathComponent = "statuses/" + statusID + "/favourited_by"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL {
var actionString: String
switch favoriteKind {
case .create:
actionString = "/favourite"
case .destroy:
actionString = "/unfavourite"
}
let pathComponent = "statuses/" + statusID + actionString
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Favourite / Undo Favourite
///
/// Add a status to your favourites list / Remove a status from your favourites list
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
/// - Parameters:
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - statusID: Mastodon status id
/// - session: `URLSession`
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Server` nested in the response
public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind)
var request = Mastodon.API.post(url: url, query: nil, authorization: authorization)
request.httpMethod = "POST"
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
/// Favourited by
///
/// View who favourited a given status.
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
/// - Parameters:
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - statusID: Mastodon status id
/// - session: `URLSession`
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Server` nested in the response
public static func favoriteBy(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID)
let request = Mastodon.API.get(url: url, query: nil, authorization: authorization)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
/// Favourited statuses
///
/// Using this endpoint to view the favourited list for user
@ -101,7 +29,12 @@ extension Mastodon.API.Favorites {
/// - session: `URLSession`
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Server` nested in the response
public static func favoritedStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
public static func favoritedStatus(
domain: String,
session: URLSession,
authorization: Mastodon.API.OAuth.Authorization,
query: Mastodon.API.Favorites.FavoriteStatusesQuery
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let url = favoritesStatusesEndpointURL(domain: domain)
let request = Mastodon.API.get(url: url, query: query, authorization: authorization)
return session.dataTaskPublisher(for: request)
@ -112,16 +45,7 @@ extension Mastodon.API.Favorites {
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Favorites {
public enum FavoriteKind {
case create
case destroy
}
public struct ListQuery: GetQuery, PagedQueryType {
public struct FavoriteStatusesQuery: GetQuery, PagedQueryType {
public var limit: Int?
public var minID: String?
@ -155,3 +79,99 @@ extension Mastodon.API.Favorites {
}
}
extension Mastodon.API.Favorites {
static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL {
var actionString: String
switch favoriteKind {
case .create:
actionString = "/favourite"
case .destroy:
actionString = "/unfavourite"
}
let pathComponent = "statuses/" + statusID + actionString
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Favourite / Undo Favourite
///
/// Add a status to your favourites list / Remove a status from your favourites list
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
/// - Parameters:
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - statusID: Mastodon status id
/// - session: `URLSession`
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Server` nested in the response
public static func favorites(
domain: String,
statusID: String,
session: URLSession,
authorization: Mastodon.API.OAuth.Authorization,
favoriteKind: FavoriteKind
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind)
var request = Mastodon.API.post(url: url, query: nil, authorization: authorization)
request.httpMethod = "POST"
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
public enum FavoriteKind {
case create
case destroy
}
}
extension Mastodon.API.Favorites {
static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL {
let pathComponent = "statuses/" + statusID + "/favourited_by"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Favourited by
///
/// View who favourited a given status.
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/3/3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
/// - Parameters:
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - statusID: Mastodon status id
/// - session: `URLSession`
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Server` nested in the response
public static func favoriteBy(
domain: String,
statusID: String,
session: URLSession,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID)
let request = Mastodon.API.get(url: url, query: nil, authorization: authorization)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}

View File

@ -18,6 +18,7 @@ extension Mastodon.Response {
// application fields
public let rateLimit: RateLimit?
public let link: Link?
public let responseTime: Int?
public var networkDate: Date {
@ -33,6 +34,11 @@ extension Mastodon.Response {
}()
self.rateLimit = RateLimit(response: response)
self.link = {
guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "link") else { return nil }
return Link(link: string)
}()
self.responseTime = {
guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil }
return Int(string)
@ -43,6 +49,7 @@ extension Mastodon.Response {
self.value = value
self.date = old.date
self.rateLimit = old.rateLimit
self.link = old.link
self.responseTime = old.responseTime
}
@ -90,3 +97,30 @@ extension Mastodon.Response {
}
}
extension Mastodon.Response {
public struct Link {
public let maxID: Mastodon.Entity.Status.ID?
public let minID: Mastodon.Entity.Status.ID?
init(link: String) {
self.maxID = {
guard let regex = try? NSRegularExpression(pattern: "max_id=([[:digit:]]+)", options: []) else { return nil }
let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex..<link.endIndex, in: link))
guard let match = results.first else { return nil }
guard let range = Range(match.range(at: 1), in: link) else { return nil }
let id = link[range]
return String(id)
}()
self.minID = {
guard let regex = try? NSRegularExpression(pattern: "min_id=([[:digit:]]+)", options: []) else { return nil }
let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex..<link.endIndex, in: link))
guard let match = results.first else { return nil }
guard let range = Range(match.range(at: 1), in: link) else { return nil }
let id = link[range]
return String(id)
}()
}
}
}

View File

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