Merge pull request #91 from tootsuite/feature/profile-coordinator

Implement profile coordinator
This commit is contained in:
CMK 2021-04-07 20:55:37 +08:00 committed by GitHub
commit e4b15cc951
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 2817 additions and 1008 deletions

View File

@ -69,6 +69,7 @@
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="String"/>
<attribute name="avatarStatic" optional="YES" attributeType="String"/>
<attribute name="bot" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
@ -78,6 +79,7 @@
<attribute name="headerStatic" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<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="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
@ -197,12 +199,12 @@
<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="629"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="659"/>
<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"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="PrivateNote" positionX="72" positionY="153" width="128" height="89"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
</elements>
</model>
</model>

View File

@ -29,6 +29,9 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var followingCount: NSNumber
@NSManaged public private(set) var followersCount: NSNumber
@NSManaged public private(set) var locked: Bool
@NSManaged public private(set) var bot: Bool
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
@ -88,6 +91,9 @@ extension MastodonUser {
user.followingCount = NSNumber(value: property.followingCount)
user.followersCount = NSNumber(value: property.followersCount)
user.locked = property.locked
user.bot = property.bot ?? false
// Mastodon do not provide relationship on the `Account`
// Update relationship via attribute updating interface
@ -158,6 +164,17 @@ extension MastodonUser {
self.followersCount = NSNumber(value: followersCount)
}
}
public func update(locked: Bool) {
if self.locked != locked {
self.locked = locked
}
}
public func update(bot: Bool) {
if self.bot != bot {
self.bot = bot
}
}
public func update(isFollowing: Bool, by mastodonUser: MastodonUser) {
if isFollowing {
if !(self.followingBy ?? Set()).contains(mastodonUser) {
@ -249,6 +266,8 @@ extension MastodonUser {
public let statusesCount: Int
public let followingCount: Int
public let followersCount: Int
public let locked: Bool
public let bot: Bool?
public let createdAt: Date
public let networkDate: Date
@ -268,6 +287,8 @@ extension MastodonUser {
statusesCount: Int,
followingCount: Int,
followersCount: Int,
locked: Bool,
bot: Bool?,
createdAt: Date,
networkDate: Date
) {
@ -286,6 +307,8 @@ extension MastodonUser {
self.statusesCount = statusesCount
self.followingCount = followingCount
self.followersCount = followersCount
self.locked = locked
self.bot = bot
self.createdAt = createdAt
self.networkDate = networkDate
}

View File

@ -5,4 +5,16 @@ Mastodon localization template file
## How to contribute?
TBD
TBD
## How to maintains
```zsh
// enter workdir
cd Mastodon
// edit i18n json
open ./Localization/app.json
// update resource
update_localization.sh
```

View File

@ -69,9 +69,16 @@
"firendship": {
"follow": "Follow",
"following": "Following",
"pending": "Pending",
"block": "Block",
"block_user": "Block %s",
"unblock": "Unblock",
"unblock_user": "Unblock %s",
"blocked": "Blocked",
"mute": "Mute",
"mute_user": "Mute %s",
"unmute": "Unmute",
"unmute_user": "Unmute %s",
"muted": "Muted",
"edit_info": "Edit info"
},
@ -79,6 +86,12 @@
"loader": {
"load_missing_posts": "Load missing posts",
"loading_missing_posts": "Loading missing posts..."
},
"header": {
"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."
}
}
},
@ -257,6 +270,16 @@
"posts": "Posts",
"replies": "Replies",
"media": "Media"
},
"relationship_action_alert": {
"confirm_unmute_user": {
"title": "Unmute Account",
"message": "Confirm unmute %s"
},
"confirm_unblock_usre": {
"title": "Unblock Account",
"message": "Confirm unblock %s"
}
}
},
"search": {
@ -282,4 +305,4 @@
"prompt": "%s people talking"
}
}
}
}

View File

@ -151,7 +151,7 @@
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; };
DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */; };
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; };
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; };
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; };
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; };
@ -261,7 +261,6 @@
DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; };
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; };
DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; };
DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; };
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; };
DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; };
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; };
@ -276,6 +275,14 @@
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; };
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; };
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */; };
DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */; };
DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F932616E28B004B8251 /* APIService+Follow.swift */; };
DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; };
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; };
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; };
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
@ -304,6 +311,8 @@
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
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 */; };
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 */
@ -507,7 +516,7 @@
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = "<group>"; };
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = "<group>"; };
DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendshipActionButton.swift; sourceTree = "<group>"; };
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = "<group>"; };
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = "<group>"; };
DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = "<group>"; };
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
@ -638,6 +647,14 @@
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = "<group>"; };
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = "<group>"; };
DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProviderFacade.swift; sourceTree = "<group>"; };
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Block.swift"; sourceTree = "<group>"; };
DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = "<group>"; };
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = "<group>"; };
DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = "<group>"; };
DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = "<group>"; };
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; };
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; };
@ -665,6 +682,8 @@
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = "<group>"; };
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.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>"; };
@ -821,6 +840,7 @@
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */,
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */,
DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */,
);
path = Content;
sourceTree = "<group>";
@ -943,6 +963,7 @@
isa = PBXGroup;
children = (
2D38F1FC25CD47D900561493 /* StatusProvider */,
DBAE3F742615DD63004B8251 /* UserProvider */,
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */,
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */,
@ -1025,6 +1046,7 @@
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */,
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */,
);
path = TableviewCell;
@ -1259,6 +1281,9 @@
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */,
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
DBAE3F932616E28B004B8251 /* APIService+Follow.swift */,
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
);
path = APIService;
sourceTree = "<group>";
@ -1578,8 +1603,10 @@
DBB525462611ED57002F1F29 /* Header */,
DBB5253B2611ECF5002F1F29 /* Timeline */,
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */,
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */,
DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */,
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */,
);
path = Profile;
@ -1612,6 +1639,7 @@
children = (
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */,
DB35FC2E26130172006193C9 /* MastodonField.swift */,
DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */,
);
path = Helper;
sourceTree = "<group>";
@ -1633,6 +1661,15 @@
path = View;
sourceTree = "<group>";
};
DBAE3F742615DD63004B8251 /* UserProvider */ = {
isa = PBXGroup;
children = (
DBAE3F672615DD60004B8251 /* UserProvider.swift */,
DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */,
);
path = UserProvider;
sourceTree = "<group>";
};
DBB525132611EBB1002F1F29 /* Segmented */ = {
isa = PBXGroup;
children = (
@ -1678,7 +1715,7 @@
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */,
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */,
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
);
path = View;
@ -2065,6 +2102,8 @@
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */,
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */,
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */,
DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */,
5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */,
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
@ -2105,6 +2144,7 @@
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
@ -2134,6 +2174,7 @@
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
@ -2148,6 +2189,7 @@
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
@ -2180,6 +2222,7 @@
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */,
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */,
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
@ -2191,7 +2234,7 @@
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */,
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */,
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
@ -2202,7 +2245,6 @@
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
@ -2213,6 +2255,7 @@
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */,
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */,
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */,
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
@ -2239,12 +2282,14 @@
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */,
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */,
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */,
@ -2308,6 +2353,7 @@
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.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>10</integer>
<integer>12</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>

View File

@ -59,6 +59,7 @@ extension SceneCoordinator {
// misc
case alertController(alertController: UIAlertController)
case safari(url: URL)
#if DEBUG
case publicTimeline
@ -114,6 +115,17 @@ extension SceneCoordinator {
guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else {
return nil
}
// adapt for child controller
if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController {
switch viewController {
case is ProfileViewController:
let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.navigationItem.title, style: .plain, target: nil, action: nil)
barButtonItem.tintColor = .white
navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem
default:
navigationControllerVisibleViewController.navigationItem.backBarButtonItem = nil
}
}
if let mainTabBarController = presentingViewController as? MainTabBarController,
let navigationController = mainTabBarController.selectedViewController as? UINavigationController,
@ -212,6 +224,10 @@ private extension SceneCoordinator {
let _viewController = ComposeViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .hashtagTimeline(let viewModel):
let _viewController = HashtagTimelineViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
@ -225,10 +241,12 @@ private extension SceneCoordinator {
)
}
viewController = alertController
case .hashtagTimeline(let viewModel):
let _viewController = HashtagTimelineViewController()
_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)
#if DEBUG
case .publicTimeline:
let _viewController = PublicTimelineViewController()

View File

@ -22,6 +22,8 @@ enum Item {
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
case publicMiddleLoader(statusID: String)
case bottomLoader
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
}
protocol StatusContentWarningAttribute {
@ -56,6 +58,30 @@ extension Item {
}
}
}
class EmptyStateHeaderAttribute: Hashable {
let id = UUID()
let reason: Reason
enum Reason {
case noStatusFound
case blocking
case blocked
case suspended
}
init(reason: Reason) {
self.reason = reason
}
static func == (lhs: Item.EmptyStateHeaderAttribute, rhs: Item.EmptyStateHeaderAttribute) -> Bool {
return lhs.reason == rhs.reason
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}
extension Item: Equatable {
@ -65,12 +91,14 @@ extension Item: Equatable {
return objectIDLeft == objectIDRight
case (.status(let objectIDLeft, _), .status(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.bottomLoader, .bottomLoader):
return true
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.bottomLoader, .bottomLoader):
return true
case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)):
return attributeLeft == attributeRight
default:
return false
}
@ -84,14 +112,16 @@ extension Item: Hashable {
hasher.combine(objectID)
case .status(let objectID, _):
hasher.combine(objectID)
case .publicMiddleLoader(let upper):
hasher.combine(String(describing: Item.publicMiddleLoader.self))
hasher.combine(upper)
case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper):
hasher.combine(String(describing: Item.homeMiddleLoader.self))
hasher.combine(upper)
case .publicMiddleLoader(let upper):
hasher.combine(String(describing: Item.publicMiddleLoader.self))
hasher.combine(upper)
case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self))
case .emptyStateHeader(let attribute):
hasher.combine(attribute)
}
}
}

View File

@ -27,16 +27,16 @@ extension CategoryPickerSection {
cell.categoryView.titleLabel.text = item.title
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
if cell.isSelected {
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
if case .all = item {
cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color
cell.categoryView.titleLabel.textColor = .white
}
} else {
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.systemBackground.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
if case .all = item {
cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color
cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color
}
}
}

View File

@ -22,6 +22,8 @@ enum ComposeStatusSection: Equatable, Hashable {
extension ComposeStatusSection {
enum ComposeKind {
case post
case hashtag(hashtag: String)
case mention(mastodonUserObjectID: NSManagedObjectID)
case reply(repliedToStatusObjectID: NSManagedObjectID)
}
}

View File

@ -9,6 +9,18 @@ import UIKit
import CoreData
import CoreDataStack
import MastodonSDK
extension Mastodon.Entity.Attachment: Hashable {
public static func == (lhs: Mastodon.Entity.Attachment, rhs: Mastodon.Entity.Attachment) -> Bool {
return lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum PollSection: Equatable, Hashable {
case main
}

View File

@ -79,12 +79,17 @@ extension StatusSection {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
case .emptyStateHeader(let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell
StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute)
return cell
}
}
}
}
extension StatusSection {
static func configure(
cell: StatusTableViewCell,
dependency: NeedsDependency,
@ -473,6 +478,14 @@ extension StatusSection {
snapshot.appendItems(pollItems, toSection: .main)
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
}
static func configureEmptyStateHeader(
cell: TimelineHeaderTableViewCell,
attribute: Item.EmptyStateHeaderAttribute
) {
cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage
cell.timelineHeaderView.messageLabel.text = attribute.reason.message
}
}
extension StatusSection {

View File

@ -64,11 +64,8 @@ extension ActiveLabel {
/// account field
func configure(field: String) {
activeEntities.removeAll()
if let parseResult = try? MastodonField.parse(field: field) {
text = parseResult.value
activeEntities = parseResult.activeEntities
} else {
text = ""
}
let parseResult = MastodonField.parse(field: field)
text = parseResult.value
activeEntities = parseResult.activeEntities
}
}

View File

@ -26,6 +26,8 @@ extension MastodonUser.Property {
statusesCount: entity.statusesCount,
followingCount: entity.followingCount,
followersCount: entity.followersCount,
locked: entity.locked,
bot: entity.bot,
createdAt: entity.createdAt,
networkDate: networkDate
)
@ -39,7 +41,12 @@ extension MastodonUser {
}
var acctWithDomain: String {
return username + "@" + domain
if !acct.contains("@") {
// Safe concat due to username cannot contains "@"
return username + "@" + domain
} else {
return acct
}
}
}

View File

@ -11,7 +11,6 @@ import UIKit
// SeeAlso: `AdaptiveStatusBarStyleNavigationController`
extension UINavigationController {
open override var childForStatusBarStyle: UIViewController? {
assertionFailure("Won't enter here")
return visibleViewController
}
}

View File

@ -46,7 +46,6 @@ internal enum Asset {
internal static let search = ColorAsset(name: "Colors/Background/search")
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")
internal static let success = ColorAsset(name: "Colors/Background/success")
internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background")
internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background")
internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background")
@ -55,6 +54,7 @@ internal enum Asset {
internal enum Button {
internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar")
internal static let disabled = ColorAsset(name: "Colors/Button/disabled")
internal static let inactive = ColorAsset(name: "Colors/Button/inactive")
internal static let normal = ColorAsset(name: "Colors/Button/normal")
}
internal enum Icon {
@ -74,26 +74,21 @@ internal enum Asset {
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
}
internal static let backgroundLight = ColorAsset(name: "Colors/backgroundLight")
internal static let buttonDefault = ColorAsset(name: "Colors/buttonDefault")
internal static let buttonDisabled = ColorAsset(name: "Colors/buttonDisabled")
internal static let buttonInactive = ColorAsset(name: "Colors/buttonInactive")
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
internal static let danger = ColorAsset(name: "Colors/danger")
internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow")
internal static let lightBackground = ColorAsset(name: "Colors/lightBackground")
internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue")
internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray")
internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled")
internal static let lightInactive = ColorAsset(name: "Colors/lightInactive")
internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText")
internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen")
internal static let lightWhite = ColorAsset(name: "Colors/lightWhite")
internal static let systemGreen = ColorAsset(name: "Colors/system.green")
internal static let disabled = ColorAsset(name: "Colors/disabled")
internal static let inactive = ColorAsset(name: "Colors/inactive")
internal static let successGreen = ColorAsset(name: "Colors/success.green")
internal static let systemOrange = ColorAsset(name: "Colors/system.orange")
}
internal enum Connectivity {
internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split")
}
internal enum Profile {
internal enum Banner {
internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray")
}
}
internal enum Welcome {
internal enum Illustration {
internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan")

View File

@ -92,6 +92,10 @@ internal enum L10n {
internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block")
/// Blocked
internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked")
/// Block %@
internal static func blockUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Firendship.BlockUser", String(describing: p1))
}
/// Edit info
internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo")
/// Follow
@ -102,6 +106,24 @@ internal enum L10n {
internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute")
/// Muted
internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.Muted")
/// Mute %@
internal static func muteUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Firendship.MuteUser", String(describing: p1))
}
/// Pending
internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending")
/// Unblock
internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock")
/// Unblock %@
internal static func unblockUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Firendship.UnblockUser", String(describing: p1))
}
/// Unmute
internal static let unmute = L10n.tr("Localizable", "Common.Controls.Firendship.Unmute")
/// Unmute %@
internal static func unmuteUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Firendship.UnmuteUser", String(describing: p1))
}
}
internal enum Status {
/// Tap to reveal that may be sensitive
@ -150,6 +172,16 @@ internal enum L10n {
}
}
internal enum Timeline {
internal enum Header {
/// You cant view Artbots profile\n until they unblock you.
internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning")
/// You cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.
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.
internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning")
}
internal enum Loader {
/// Loading missing posts...
internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts")
@ -296,6 +328,24 @@ internal enum L10n {
/// posts
internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts")
}
internal enum RelationshipActionAlert {
internal enum ConfirmUnblockUsre {
/// Confirm unblock %@
internal static func message(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message", String(describing: p1))
}
/// Unblock Account
internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title")
}
internal enum ConfirmUnmuteUser {
/// Confirm unmute %@
internal static func message(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message", String(describing: p1))
}
/// Unmute Account
internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title")
}
}
internal enum SegmentedControl {
/// Media
internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media")

View File

@ -11,7 +11,7 @@ import ActiveLabel
enum MastodonField {
static func parse(field string: String) -> ParseResult {
let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+))")
let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?)")
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")

View File

@ -0,0 +1,41 @@
//
// MastodonMetricFormatter.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-2.
//
import Foundation
final class MastodonMetricFormatter: Formatter {
func string(from number: Int) -> String? {
let isPositive = number >= 0
let symbol = isPositive ? "" : "-"
let numberFormatter = NumberFormatter()
let value = abs(number)
let metric: String
switch value {
case 0..<1000: // 0 ~ 1K
metric = String(value)
case 1000..<10000: // 1K ~ 10K
numberFormatter.maximumFractionDigits = 1
let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000)
metric = string + "K"
case 10000..<1000000: // 10K ~ 1M
numberFormatter.maximumFractionDigits = 0
let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000)
metric = string + "K"
default:
numberFormatter.maximumFractionDigits = 0
let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000000.0)) ?? String(value / 1000000)
metric = string + "M"
}
return symbol + metric
}
}

View File

@ -84,6 +84,8 @@ extension AvatarConfigurableView {
completion: nil
)
}
configureLayerBorder(view: avatarImageView, configuration: configuration)
}
if let avatarButton = configurableAvatarButton {
@ -110,9 +112,24 @@ extension AvatarConfigurableView {
completion: nil
)
}
configureLayerBorder(view: avatarButton, configuration: configuration)
}
}
func configureLayerBorder(view: UIView, configuration: AvatarConfigurableViewConfiguration) {
guard let borderWidth = configuration.borderWidth, borderWidth > 0,
let borderColor = configuration.borderColor else {
return
}
view.layer.masksToBounds = true
view.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
view.layer.cornerCurve = .continuous
view.layer.borderColor = borderColor.cgColor
view.layer.borderWidth = borderWidth
}
func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration) { }
}
@ -121,10 +138,19 @@ struct AvatarConfigurableViewConfiguration {
let avatarImageURL: URL?
let placeholderImage: UIImage?
let borderColor: UIColor?
let borderWidth: CGFloat?
init(avatarImageURL: URL?, placeholderImage: UIImage? = nil) {
init(
avatarImageURL: URL?,
placeholderImage: UIImage? = nil,
borderColor: UIColor? = nil,
borderWidth: CGFloat? = nil
) {
self.avatarImageURL = avatarImageURL
self.placeholderImage = placeholderImage
self.borderColor = borderColor
self.borderWidth = borderWidth
}
}

View File

@ -24,6 +24,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell)
}
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity)
}
}
// MARK: - ActionToolbarContainerDelegate
@ -205,21 +209,3 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
}
}
// MARK: - ActiveLabel didSelect ActiveEntity
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) {
switch entity.type {
case .hashtag(let hashtag, let userInfo):
let hashtagTimelienViewModel = HashtagTimelineViewModel(context: context, hashTag: hashtag)
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: self, transition: .show)
break
case .email(let content, let userInfo):
break
case .mention(let mention, let userInfo):
break
case .url(let content, let trimmed, let url, let userInfo):
break
}
}
}

View File

@ -62,6 +62,69 @@ extension StatusProviderFacade {
}
}
extension StatusProviderFacade {
static func responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) {
switch entity.type {
case .hashtag(let text, _):
let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: text)
provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: provider, transition: .show)
case .mention(let text, _):
coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text)
case .url(_, _, let url, _):
guard let url = URL(string: url) else { return }
provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
default:
break
}
}
private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String) {
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let domain = activeMastodonAuthenticationBox.domain
provider.status(for: cell, indexPath: nil)
.sink { [weak provider] status in
guard let provider = provider else { return }
let _status: Status? = {
switch target {
case .primary: return status?.reblog ?? status
case .secondary: return status
}
}()
guard let status = _status else { return }
// cannot continue without meta
guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { return }
let userID = mentionMeta.id
let profileViewModel: ProfileViewModel = {
// check if self
guard userID != activeMastodonAuthenticationBox.userID else {
return MeProfileViewModel(context: provider.context)
}
let request = MastodonUser.sortedFetchRequest
request.fetchLimit = 1
request.predicate = MastodonUser.predicate(domain: domain, id: userID)
let mastodonUser = provider.context.managedObjectContext.safeFetch(request).first
if let mastodonUser = mastodonUser {
return CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser)
} else {
return RemoteProfileViewModel(context: provider.context, userID: userID)
}
}()
DispatchQueue.main.async {
provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show)
}
}
.store(in: &provider.disposeBag)
}
}
extension StatusProviderFacade {
static func responseToStatusLikeAction(provider: StatusProvider) {

View File

@ -0,0 +1,16 @@
//
// UserProvider.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-1.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
protocol UserProvider: NeedsDependency & DisposeBagCollectable & UIViewController {
// async
func mastodonUser() -> Future<MastodonUser?, Never>
}

View File

@ -0,0 +1,204 @@
//
// UserProviderFacade.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-1.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
enum UserProviderFacade { }
extension UserProviderFacade {
static func toggleUserFollowRelationship(
provider: UserProvider
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
return _toggleUserFollowRelationship(
context: provider.context,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
mastodonUser: provider.mastodonUser().eraseToAnyPublisher()
)
}
private static func _toggleUserFollowRelationship(
context: AppContext,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
mastodonUser: AnyPublisher<MastodonUser?, Never>
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
mastodonUser
.compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? in
guard let mastodonUser = mastodonUser else {
return nil
}
return context.apiService.toggleFollow(
for: mastodonUser,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.switchToLatest()
.eraseToAnyPublisher()
}
}
extension UserProviderFacade {
static func toggleUserBlockRelationship(
provider: UserProvider
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
return _toggleUserBlockRelationship(
context: provider.context,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
mastodonUser: provider.mastodonUser().eraseToAnyPublisher()
)
}
private static func _toggleUserBlockRelationship(
context: AppContext,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
mastodonUser: AnyPublisher<MastodonUser?, Never>
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
mastodonUser
.compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? in
guard let mastodonUser = mastodonUser else {
return nil
}
return context.apiService.toggleBlock(
for: mastodonUser,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.switchToLatest()
.eraseToAnyPublisher()
}
}
extension UserProviderFacade {
static func toggleUserMuteRelationship(
provider: UserProvider
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
return _toggleUserMuteRelationship(
context: provider.context,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
mastodonUser: provider.mastodonUser().eraseToAnyPublisher()
)
}
private static func _toggleUserMuteRelationship(
context: AppContext,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
mastodonUser: AnyPublisher<MastodonUser?, Never>
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
mastodonUser
.compactMap { mastodonUser -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? in
guard let mastodonUser = mastodonUser else {
return nil
}
return context.apiService.toggleMute(
for: mastodonUser,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.switchToLatest()
.eraseToAnyPublisher()
}
}
extension UserProviderFacade {
static func createProfileActionMenu(
for mastodonUser: MastodonUser,
isMuting: Bool,
isBlocking: Bool,
provider: UserProvider
) -> UIMenu {
var children: [UIMenuElement] = []
let name = mastodonUser.displayNameWithFallback
// mute
let muteAction = UIAction(
title: isMuting ? L10n.Common.Controls.Firendship.unmuteUser(name) : L10n.Common.Controls.Firendship.mute,
image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"),
discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Firendship.muteUser(name),
attributes: isMuting ? [] : .destructive,
state: .off
) { [weak provider] _ in
guard let provider = provider else { return }
UserProviderFacade.toggleUserMuteRelationship(
provider: provider
)
.sink { _ in
// do nothing
} receiveValue: { _ in
// do nothing
}
.store(in: &provider.context.disposeBag)
}
if isMuting {
children.append(muteAction)
} else {
let muteMenu = UIMenu(title: L10n.Common.Controls.Firendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction])
children.append(muteMenu)
}
// block
let blockAction = UIAction(
title: isBlocking ? L10n.Common.Controls.Firendship.unblockUser(name) : L10n.Common.Controls.Firendship.block,
image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"),
discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Firendship.blockUser(name),
attributes: isBlocking ? [] : .destructive,
state: .off
) { [weak provider] _ in
guard let provider = provider else { return }
UserProviderFacade.toggleUserBlockRelationship(
provider: provider
)
.sink { _ in
// do nothing
} receiveValue: { _ in
// do nothing
}
.store(in: &provider.context.disposeBag)
}
if isBlocking {
children.append(blockAction)
} else {
let blockMenu = UIMenu(title: L10n.Common.Controls.Firendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction])
children.append(blockMenu)
}
return UIMenu(title: "", options: [], children: children)
}
}

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "255",
"green" : "255",
"red" : "255"
"blue" : "0xFE",
"green" : "0xFF",
"red" : "0xFE"
}
},
"idiom" : "universal"
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x37",
"green" : "0x2D",
"red" : "0x29"
"blue" : "0x2E",
"green" : "0x2C",
"red" : "0x2C"
}
},
"idiom" : "universal"

View File

@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.216",
"green" : "0.176",
"red" : "0.161"
"blue" : "0x00",
"green" : "0x00",
"red" : "0x00"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"blue" : "0xFE",
"green" : "0xFF",
"red" : "0xFF"
"red" : "0xFE"
}
},
"idiom" : "universal"
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x2B",
"green" : "0x23",
"red" : "0x1F"
"blue" : "0x2E",
"green" : "0x2C",
"red" : "0x2C"
}
},
"idiom" : "universal"

View File

@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x2B",
"green" : "0x23",
"red" : "0x1F"
"blue" : "0x00",
"green" : "0x00",
"red" : "0x00"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,27 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "140",
"green" : "130",
"red" : "110"
"blue" : "0.784",
"green" : "0.682",
"red" : "0.608"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.392",
"green" : "0.365",
"red" : "0.310"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.549",
"green" : "0.510",
"red" : "0.431"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.392",
"green" : "0.365",
"red" : "0.310"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "217",
"green" : "144",
"red" : "43"
"blue" : "0xD9",
"green" : "0x90",
"red" : "0x2B"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "0.600",
"blue" : "67",
"green" : "60",
"red" : "60"
"blue" : "0x43",
"green" : "0x3C",
"red" : "0x3C"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xD9",
"green" : "0x90",
"red" : "0x2B"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE4",
"green" : "0x9D",
"red" : "0x3A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "200",
"green" : "174",
"red" : "155"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x64",
"green" : "0x5D",
"red" : "0x4F"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x8C",
"green" : "0x82",
"red" : "0x6E"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x64",
"green" : "0x5D",
"red" : "0x4F"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -4,10 +4,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.910",
"green" : "0.882",
"red" : "0.851"
"alpha" : "0.600",
"blue" : "0.961",
"green" : "0.922",
"red" : "0.922"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -29,12 +29,19 @@ Please check your internet connection.";
"Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Actions.TryAgain" = "Try Again";
"Common.Controls.Firendship.Block" = "Block";
"Common.Controls.Firendship.BlockUser" = "Block %@";
"Common.Controls.Firendship.Blocked" = "Blocked";
"Common.Controls.Firendship.EditInfo" = "Edit info";
"Common.Controls.Firendship.Follow" = "Follow";
"Common.Controls.Firendship.Following" = "Following";
"Common.Controls.Firendship.Mute" = "Mute";
"Common.Controls.Firendship.MuteUser" = "Mute %@";
"Common.Controls.Firendship.Muted" = "Muted";
"Common.Controls.Firendship.Pending" = "Pending";
"Common.Controls.Firendship.Unblock" = "Unblock";
"Common.Controls.Firendship.UnblockUser" = "Unblock %@";
"Common.Controls.Firendship.Unmute" = "Unmute";
"Common.Controls.Firendship.UnmuteUser" = "Unmute %@";
"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive";
"Common.Controls.Status.Poll.Closed" = "Closed";
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
@ -47,6 +54,13 @@ Please check your internet connection.";
"Common.Controls.Status.StatusContentWarning" = "content warning";
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
"Common.Controls.Timeline.Header.BlockedWarning" = "You cant view Artbots profile
until they unblock you.";
"Common.Controls.Timeline.Header.BlockingWarning" = "You cant view Artbots profile
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.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Countable.Photo.Multiple" = "photos";
@ -97,6 +111,10 @@ tap the link to confirm your account.";
"Scene.Profile.Dashboard.Followers" = "followers";
"Scene.Profile.Dashboard.Following" = "following";
"Scene.Profile.Dashboard.Posts" = "posts";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account";
"Scene.Profile.SegmentedControl.Media" = "Media";
"Scene.Profile.SegmentedControl.Posts" = "Posts";
"Scene.Profile.SegmentedControl.Replies" = "Replies";

View File

@ -538,7 +538,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string)
let stringRange = NSRange(location: 0, length: string.length)
let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s.]+))")
let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?|#([^\\s.]+))")
// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
// precondition :\B with following space
let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))")

View File

@ -62,7 +62,7 @@ extension ComposeViewModel {
case .reply(let statusObjectID):
snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo)
snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo)
case .post:
case .hashtag, .mention, .post:
snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status)
}
diffableDataSource.apply(snapshot, animatingDifferences: false)

View File

@ -56,8 +56,9 @@ final class ComposeViewModel {
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
let characterCount = CurrentValueSubject<Int, Never>(0)
// In some specific scenes(hashtag scene e.g.), we need to display the compose scene with pre-inserted text(insert '#mastodon ' in #mastodon hashtag scene, e.g.), the pre-inserted text should be treated as mannually inputed by users.
var preInsertedContent: String? = nil
// for hashtag: #<hashag>' '
// for mention: @<mention>' '
private(set) var preInsertedContent: String?
// custom emojis
var customEmojiViewModelSubscription: AnyCancellable?
@ -74,19 +75,35 @@ final class ComposeViewModel {
init(
context: AppContext,
composeKind: ComposeStatusSection.ComposeKind,
preInsertedContent: String? = nil
composeKind: ComposeStatusSection.ComposeKind
) {
self.context = context
self.composeKind = composeKind
self.preInsertedContent = preInsertedContent
switch composeKind {
case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
case .post, .hashtag, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
}
self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value)
self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
// end init
if case let .hashtag(text) = composeKind {
let initialComposeContent = "#" + text
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.preInsertedContent = preInsertedContent
self.composeStatusAttribute.composeContent.value = preInsertedContent
} else if case let .mention(mastodonUserObjectID) = composeKind {
context.managedObjectContext.performAndWait {
let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser
let initialComposeContent = "@" + mastodonUser.acct
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.preInsertedContent = preInsertedContent
self.composeStatusAttribute.composeContent.value = preInsertedContent
}
} else {
self.preInsertedContent = nil
}
isCustomEmojiComposing
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)

View File

@ -53,8 +53,8 @@ extension HashtagTimelineViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = "#\(viewModel.hashTag)"
titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: nil)
title = "#\(viewModel.hashtag)"
titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil)
navigationItem.titleView = titleView
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
@ -142,7 +142,7 @@ extension HashtagTimelineViewController {
private func updatePromptTitle() {
var subtitle: String?
defer {
titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: subtitle)
titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle)
}
guard let histories = viewModel.hashtagEntity.value?.history else {
return
@ -167,7 +167,7 @@ extension HashtagTimelineViewController {
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashTag)")
let composeViewModel = ComposeViewModel(context: context, composeKind: .hashtag(hashtag: viewModel.hashtag))
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
}

View File

@ -50,7 +50,7 @@ extension HashtagTimelineViewModel.LoadLatestState {
// TODO: only set large count when using Wi-Fi
viewModel.context.apiService.hashtagTimeline(
domain: activeMastodonAuthenticationBox.domain,
hashtag: viewModel.hashTag,
hashtag: viewModel.hashtag,
authorizationBox: activeMastodonAuthenticationBox)
.receive(on: DispatchQueue.main)
.sink { completion in

View File

@ -67,7 +67,7 @@ extension HashtagTimelineViewModel.LoadMiddleState {
viewModel.context.apiService.hashtagTimeline(
domain: activeMastodonAuthenticationBox.domain,
maxID: maxID,
hashtag: viewModel.hashTag,
hashtag: viewModel.hashtag,
authorizationBox: activeMastodonAuthenticationBox)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)

View File

@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadOldestState {
viewModel.context.apiService.hashtagTimeline(
domain: activeMastodonAuthenticationBox.domain,
maxID: maxID,
hashtag: viewModel.hashTag,
hashtag: viewModel.hashtag,
authorizationBox: activeMastodonAuthenticationBox)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)

View File

@ -15,7 +15,7 @@ import MastodonSDK
final class HashtagTimelineViewModel: NSObject {
let hashTag: String
let hashtag: String
var disposeBag = Set<AnyCancellable>()
@ -65,9 +65,9 @@ final class HashtagTimelineViewModel: NSObject {
var cellFrameCache = NSCache<NSNumber, NSValue>()
init(context: AppContext, hashTag: String) {
init(context: AppContext, hashtag: String) {
self.context = context
self.hashTag = hashTag
self.hashtag = hashtag
let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value
self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil)
super.init()
@ -84,13 +84,13 @@ final class HashtagTimelineViewModel: NSObject {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
let query = Mastodon.API.Search.Query(q: hashTag, type: .hashtags)
let query = Mastodon.API.Search.Query(q: hashtag, type: .hashtags)
context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { _ in
} receiveValue: { [weak self] response in
let matchedTag = response.value.hashtags.first { tag -> Bool in
return tag.name == self?.hashTag
return tag.name == self?.hashtag
}
self?.hashtagEntity.send(matchedTag)
}

View File

@ -120,7 +120,7 @@ extension HomeTimelineNavigationBarTitleView {
configureButton(
title: L10n.Scene.HomeTimeline.NavigationBarState.published,
textColor: .white,
backgroundColor: Asset.Colors.Background.success.color
backgroundColor: Asset.Colors.successGreen.color
)
button.isHidden = false

View File

@ -40,7 +40,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc
let openEmailButton: UIButton = {
let button = UIButton(type: .system)
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightBrandBlue.color), for: .normal)
button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color), for: .normal)
button.setTitleColor(.white, for: .normal)
button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal)
button.layer.masksToBounds = true
@ -53,7 +53,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc
let dontReceiveButton: UIButton = {
let button = UIButton(type: .system)
button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.boldSystemFont(ofSize: 15))
button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.setTitle(L10n.Scene.ConfirmEmail.Button.dontReceiveEmail, for: .normal)
button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside)
return button

View File

@ -27,7 +27,7 @@ class PickServerCell: UITableViewCell {
let containerView: UIView = {
let view = UIView()
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
view.backgroundColor = Asset.Colors.lightWhite.color
view.backgroundColor = Asset.Colors.Background.systemBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
@ -35,7 +35,7 @@ class PickServerCell: UITableViewCell {
let domainLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = Asset.Colors.lightDarkGray.color
label.textColor = Asset.Colors.Label.primary.color
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
@ -44,7 +44,7 @@ class PickServerCell: UITableViewCell {
let checkbox: UIImageView = {
let imageView = UIImageView()
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
imageView.tintColor = Asset.Colors.lightSecondaryText.color
imageView.tintColor = Asset.Colors.Label.secondary.color
imageView.contentMode = .scaleAspectFill
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
@ -54,7 +54,7 @@ class PickServerCell: UITableViewCell {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .subheadline)
label.numberOfLines = 0
label.textColor = Asset.Colors.lightDarkGray.color
label.textColor = Asset.Colors.Label.primary.color
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
@ -90,7 +90,7 @@ class PickServerCell: UITableViewCell {
let button = UIButton(type: .custom)
button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal)
button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected)
button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.titleLabel?.font = .preferredFont(forTextStyle: .footnote)
button.translatesAutoresizingMaskIntoConstraints = false
return button
@ -98,14 +98,14 @@ class PickServerCell: UITableViewCell {
let seperator: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.lightBackground.color
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let langValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
label.textAlignment = .center
label.adjustsFontForContentSizeCategory = true
@ -115,7 +115,7 @@ class PickServerCell: UITableViewCell {
let usersValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
label.textAlignment = .center
label.adjustsFontForContentSizeCategory = true
@ -125,7 +125,7 @@ class PickServerCell: UITableViewCell {
let categoryValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.textColor = Asset.Colors.Label.primary.color
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
label.textAlignment = .center
label.adjustsFontForContentSizeCategory = true
@ -135,7 +135,7 @@ class PickServerCell: UITableViewCell {
let langTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.textColor = Asset.Colors.Label.primary.color
label.font = .preferredFont(forTextStyle: .caption2)
label.text = L10n.Scene.ServerPicker.Label.language
label.textAlignment = .center
@ -146,7 +146,7 @@ class PickServerCell: UITableViewCell {
let usersTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.textColor = Asset.Colors.Label.primary.color
label.font = .preferredFont(forTextStyle: .caption2)
label.text = L10n.Scene.ServerPicker.Label.users
label.textAlignment = .center
@ -157,7 +157,7 @@ class PickServerCell: UITableViewCell {
let categoryTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.textColor = Asset.Colors.Label.primary.color
label.font = .preferredFont(forTextStyle: .caption2)
label.text = L10n.Scene.ServerPicker.Label.category
label.textAlignment = .center

View File

@ -17,7 +17,7 @@ class PickServerSearchCell: UITableViewCell {
private var bgView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.lightWhite.color
view.backgroundColor = Asset.Colors.Background.systemBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.maskedCorners = [
.layerMinXMinYCorner,
@ -30,7 +30,7 @@ class PickServerSearchCell: UITableViewCell {
private var textFieldBgView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.lightBackground.color.withAlphaComponent(0.6)
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color.withAlphaComponent(0.6)
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.masksToBounds = true
view.layer.cornerRadius = 6
@ -42,13 +42,13 @@ class PickServerSearchCell: UITableViewCell {
let textField = UITextField()
textField.translatesAutoresizingMaskIntoConstraints = false
textField.font = .preferredFont(forTextStyle: .headline)
textField.tintColor = Asset.Colors.lightDarkGray.color
textField.textColor = Asset.Colors.lightDarkGray.color
textField.tintColor = Asset.Colors.Label.primary.color
textField.textColor = Asset.Colors.Label.primary.color
textField.adjustsFontForContentSizeCategory = true
textField.attributedPlaceholder =
NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder,
attributes: [.font: UIFont.preferredFont(forTextStyle: .headline),
.foregroundColor: Asset.Colors.lightSecondaryText.color.withAlphaComponent(0.6)])
.foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)])
textField.clearButtonMode = .whileEditing
textField.autocapitalizationType = .none
textField.autocorrectionType = .no

View File

@ -48,7 +48,7 @@ extension PickServerCategoryView {
addSubview(bgView)
addSubview(titleLabel)
bgView.backgroundColor = Asset.Colors.lightWhite.color
bgView.backgroundColor = Asset.Colors.Background.systemBackground.color
NSLayoutConstraint.activate([
bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor),

View File

@ -21,7 +21,7 @@ final class ProfileHeaderViewController: UIViewController {
weak var delegate: ProfileHeaderViewControllerDelegate?
let profileBannerView = ProfileHeaderView()
let profileHeaderView = ProfileHeaderView()
let pageSegmentedControl: UISegmentedControl = {
let segmenetedControl = UISegmentedControl(items: ["A", "B"])
segmenetedControl.selectedSegmentIndex = 0
@ -31,7 +31,7 @@ final class ProfileHeaderViewController: UIViewController {
private var isBannerPinned = false
private var bottomShadowAlpha: CGFloat = 0.0
private var isAdjustBannerImageViewForSafeAreaInset = false
// private var isAdjustBannerImageViewForSafeAreaInset = false
private var containerSafeAreaInset: UIEdgeInsets = .zero
deinit {
@ -47,19 +47,19 @@ extension ProfileHeaderViewController {
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
profileBannerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(profileBannerView)
profileHeaderView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(profileHeaderView)
NSLayoutConstraint.activate([
profileBannerView.topAnchor.constraint(equalTo: view.topAnchor),
profileBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
profileBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor),
profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
profileBannerView.preservesSuperviewLayoutMargins = true
profileHeaderView.preservesSuperviewLayoutMargins = true
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pageSegmentedControl)
NSLayoutConstraint.activate([
pageSegmentedControl.topAnchor.constraint(equalTo: profileBannerView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight),
pageSegmentedControl.topAnchor.constraint(equalTo: profileHeaderView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight),
pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight),
@ -72,11 +72,13 @@ extension ProfileHeaderViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !isAdjustBannerImageViewForSafeAreaInset {
isAdjustBannerImageViewForSafeAreaInset = true
profileBannerView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top
profileBannerView.bannerImageView.frame.size.height += containerSafeAreaInset.top
}
// Deprecated:
// not needs this tweak due to force layout update in the parent
// if !isAdjustBannerImageViewForSafeAreaInset {
// isAdjustBannerImageViewForSafeAreaInset = true
// profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top
// profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top
// }
}
override func viewDidLayoutSubviews() {
@ -115,13 +117,13 @@ extension ProfileHeaderViewController {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
updateHeaderBottomShadow(progress: progress)
let bannerImageView = profileBannerView.bannerImageView
let bannerImageView = profileHeaderView.bannerImageView
guard bannerImageView.bounds != .zero else {
// wait layout finish
return
}
let bannerContainerInWindow = profileBannerView.convert(profileBannerView.bannerContainerView.frame, to: nil)
let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil)
let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height
if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {

View File

@ -1,71 +0,0 @@
//
// ProfileFriendshipActionButton.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import UIKit
final class ProfileFriendshipActionButton: RoundedEdgesButton {
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileFriendshipActionButton {
private func _init() {
configure(state: .follow)
}
}
extension ProfileFriendshipActionButton {
enum State {
case follow
case following
case blocked
case muted
case edit
case editing
var title: String {
switch self {
case .follow: return L10n.Common.Controls.Firendship.follow
case .following: return L10n.Common.Controls.Firendship.following
case .blocked: return L10n.Common.Controls.Firendship.blocked
case .muted: return L10n.Common.Controls.Firendship.muted
case .edit: return L10n.Common.Controls.Firendship.editInfo
case .editing: return L10n.Common.Controls.Actions.done
}
}
var backgroundColor: UIColor {
switch self {
case .follow: return Asset.Colors.Button.normal.color
case .following: return Asset.Colors.Button.normal.color
case .blocked: return Asset.Colors.Background.danger.color
case .muted: return Asset.Colors.Background.alertYellow.color
case .edit: return Asset.Colors.Button.normal.color
case .editing: return Asset.Colors.Button.normal.color
}
}
}
private func configure(state: State) {
setTitle(state.title, for: .normal)
setTitleColor(.white, for: .normal)
setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted)
setBackgroundImage(.placeholder(color: state.backgroundColor), for: .normal)
setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
}
}

View File

@ -10,6 +10,7 @@ import UIKit
import ActiveLabel
protocol ProfileHeaderViewDelegate: class {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
@ -22,6 +23,7 @@ final class ProfileHeaderView: UIView {
static let avatarImageViewSize = CGSize(width: 56, height: 56)
static let avatarImageViewCornerRadius: CGFloat = 6
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
static let bannerImageViewPlaceholderColor = UIColor.systemGray
weak var delegate: ProfileHeaderViewDelegate?
@ -29,10 +31,19 @@ final class ProfileHeaderView: UIView {
let bannerImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.image = .placeholder(color: .systemGray)
imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor)
imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor
imageView.layer.masksToBounds = true
// #if DEBUG
// imageView.image = .placeholder(color: .red)
// #endif
return imageView
}()
let bannerImageViewOverlayView: UIView = {
let overlayView = UIView()
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
return overlayView
}()
let avatarImageView: UIImageView = {
let imageView = UIImageView()
@ -59,14 +70,18 @@ final class ProfileHeaderView: UIView {
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.textColor = .white
label.textColor = Asset.Profile.Banner.usernameGray.color
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 statusDashboardView = ProfileStatusDashboardView()
let friendshipActionButton = ProfileFriendshipActionButton()
let relationshipActionButton: ProfileRelationshipActionButton = {
let button = ProfileRelationshipActionButton()
button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
return button
}()
let bioContainerView = UIView()
let fieldContainerStackView = UIStackView()
@ -103,6 +118,15 @@ extension ProfileHeaderView {
bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
bannerImageView.frame = bannerContainerView.bounds
bannerContainerView.addSubview(bannerImageView)
bannerImageViewOverlayView.translatesAutoresizingMaskIntoConstraints = false
bannerImageView.addSubview(bannerImageViewOverlayView)
NSLayoutConstraint.activate([
bannerImageViewOverlayView.topAnchor.constraint(equalTo: bannerImageView.topAnchor),
bannerImageViewOverlayView.leadingAnchor.constraint(equalTo: bannerImageView.leadingAnchor),
bannerImageViewOverlayView.trailingAnchor.constraint(equalTo: bannerImageView.trailingAnchor),
bannerImageViewOverlayView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor),
])
// avatar
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
@ -156,14 +180,14 @@ extension ProfileHeaderView {
statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor),
])
friendshipActionButton.translatesAutoresizingMaskIntoConstraints = false
dashboardContainerView.addSubview(friendshipActionButton)
relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false
dashboardContainerView.addSubview(relationshipActionButton)
NSLayoutConstraint.activate([
friendshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor),
friendshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8),
friendshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor),
friendshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh),
friendshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh),
relationshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor),
relationshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8),
relationshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor),
relationshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh),
relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh),
])
bioContainerView.preservesSuperviewLayoutMargins = true
@ -184,10 +208,20 @@ extension ProfileHeaderView {
bringSubviewToFront(nameContainerStackView)
bioActiveLabel.delegate = self
relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside)
}
}
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)
assert(sender === relationshipActionButton)
delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton)
}
}
// MARK: - ActiveLabelDelegate
extension ProfileHeaderView: ActiveLabelDelegate {
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {

View File

@ -0,0 +1,46 @@
//
// ProfileRelationshipActionButton.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import UIKit
final class ProfileRelationshipActionButton: RoundedEdgesButton {
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileRelationshipActionButton {
private func _init() {
// do nothing
}
}
extension ProfileRelationshipActionButton {
func configure(actionOptionSet: ProfileViewModel.RelationshipActionOptionSet) {
setTitle(actionOptionSet.title, for: .normal)
setTitleColor(.white, for: .normal)
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)
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked {
isEnabled = false
} else {
isEnabled = true
}
}
}

View File

@ -0,0 +1,20 @@
//
// ProfileViewController+UserProvider.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-1.
//
import Foundation
import Combine
import CoreDataStack
extension ProfileViewController: UserProvider {
func mastodonUser() -> Future<MastodonUser?, Never> {
return Future { promise in
promise(.success(self.viewModel.mastodonUser.value))
}
}
}

View File

@ -18,11 +18,17 @@ final class ProfileViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
var viewModel: ProfileViewModel!
private var preferredStatusBarStyleForBanner: UIStatusBarStyle = .lightContent {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
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
return barButtonItem
}()
let moreMenuBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil)
barButtonItem.tintColor = .white
return barButtonItem
}()
let refreshControl: UIRefreshControl = {
let refreshControl = UIRefreshControl()
@ -78,7 +84,7 @@ extension ProfileViewController {
height: bottomPageHeight + headerViewHeight
)
self.overlayScrollView.contentSize = contentSize
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription)
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription)
}
}
@ -86,7 +92,7 @@ extension ProfileViewController {
extension ProfileViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return preferredStatusBarStyleForBanner
return .lightContent
}
override func viewSafeAreaInsetsDidChange() {
@ -95,82 +101,56 @@ extension ProfileViewController {
profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets)
}
override var isViewLoaded: Bool {
return super.isViewLoaded
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
let barAppearance = UINavigationBarAppearance()
barAppearance.configureWithTransparentBackground()
navigationItem.standardAppearance = barAppearance
navigationItem.compactAppearance = barAppearance
navigationItem.scrollEdgeAppearance = barAppearance
navigationItem.titleView = UIView()
// if navigationController?.viewControllers.first == self {
// navigationItem.leftBarButtonItem = avatarBarButtonItem
// }
// avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside)
// unmuteMenuBarButtonItem.target = self
// unmuteMenuBarButtonItem.action = #selector(ProfileViewController.unmuteBarButtonItemPressed(_:))
// Publishers.CombineLatest4(
// viewModel.muted.eraseToAnyPublisher(),
// viewModel.blocked.eraseToAnyPublisher(),
// viewModel.twitterUser.eraseToAnyPublisher(),
// context.authenticationService.activeTwitterAuthenticationBox.eraseToAnyPublisher()
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] muted, blocked, twitterUser, activeTwitterAuthenticationBox in
// guard let self = self else { return }
// guard let twitterUser = twitterUser,
// let activeTwitterAuthenticationBox = activeTwitterAuthenticationBox,
// twitterUser.id != activeTwitterAuthenticationBox.twitterUserID else {
// self.navigationItem.rightBarButtonItems = []
// return
// }
//
// if #available(iOS 14.0, *) {
// self.moreMenuBarButtonItem.target = nil
// self.moreMenuBarButtonItem.action = nil
// self.moreMenuBarButtonItem.menu = UserProviderFacade.createMenuForUser(
// twitterUser: twitterUser,
// muted: muted,
// blocked: blocked,
// dependency: self
// )
// } else {
// // no menu supports for early version
// self.moreMenuBarButtonItem.target = self
// self.moreMenuBarButtonItem.action = #selector(ProfileViewController.moreMenuBarButtonItemPressed(_:))
// }
//
// var rightBarButtonItems: [UIBarButtonItem] = [self.moreMenuBarButtonItem]
// if muted {
// rightBarButtonItems.append(self.unmuteMenuBarButtonItem)
// }
//
// self.navigationItem.rightBarButtonItems = rightBarButtonItems
// }
// .store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
guard let self = self else { return }
var items: [UIBarButtonItem] = []
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)
overlayScrollView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged)
// drawerSidebarTransitionController = DrawerSidebarTransitionController(drawerSidebarTransitionableViewController: self)
let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter())
viewModel.domain.assign(to: \.value, on: postsUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: postsUserTimelineViewModel.userID).store(in: &disposeBag)
bind(userTimelineViewModel: postsUserTimelineViewModel)
let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true))
viewModel.domain.assign(to: \.value, on: repliesUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: repliesUserTimelineViewModel.userID).store(in: &disposeBag)
bind(userTimelineViewModel: repliesUserTimelineViewModel)
let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true))
viewModel.domain.assign(to: \.value, on: mediaUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: mediaUserTimelineViewModel.userID).store(in: &disposeBag)
bind(userTimelineViewModel: mediaUserTimelineViewModel)
profileSegmentedViewController.pagingViewController.viewModel = {
let profilePagingViewModel = ProfilePagingViewModel(
@ -244,23 +224,15 @@ extension ProfileViewController {
profileHeaderViewController.delegate = self
profileSegmentedViewController.pagingViewController.pagingDelegate = self
// // add segmented bar to header
// profileSegmentedViewController.pagingViewController.addBar(
// bar,
// dataSource: profileSegmentedViewController.pagingViewController.viewModel,
// at: .custom(view: profileHeaderViewController.view, layout: { bar in
// bar.translatesAutoresizingMaskIntoConstraints = false
// self.profileHeaderViewController.view.addSubview(bar)
// NSLayoutConstraint.activate([
// bar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor),
// bar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor),
// bar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor),
// bar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.headerMinHeight).priority(.defaultHigh),
// ])
// })
// )
// bind view model
viewModel.name
.receive(on: DispatchQueue.main)
.sink { [weak self] name in
guard let self = self else { return }
self.navigationItem.title = name
}
.store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.bannerImageURL.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
@ -268,56 +240,29 @@ extension ProfileViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] bannerImageURL, _ in
guard let self = self else { return }
self.profileHeaderViewController.profileBannerView.bannerImageView.af.cancelImageRequest()
let placeholder = UIImage.placeholder(color: Asset.Colors.Background.systemGroupedBackground.color)
self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest()
let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor)
guard let bannerImageURL = bannerImageURL else {
self.profileHeaderViewController.profileBannerView.bannerImageView.image = placeholder
self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder
return
}
self.profileHeaderViewController.profileBannerView.bannerImageView.af.setImage(
self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage(
withURL: bannerImageURL,
placeholderImage: placeholder,
imageTransition: .crossDissolve(0.3),
runImageTransitionIfCached: false,
completion: { [weak self] response in
guard let self = self else { return }
switch response.result {
case .success(let image):
self.viewModel.headerDomainLumaStyle.value = image.domainLumaCoefficientsStyle ?? .dark
case .failure:
break
guard let image = response.value else { return }
guard image.size.width > 1 && image.size.height > 1 else {
// restore to placeholder when image invalid
self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder
return
}
}
)
}
.store(in: &disposeBag)
viewModel.headerDomainLumaStyle
.receive(on: DispatchQueue.main)
.sink { [weak self] style in
guard let self = self else { return }
let textColor: UIColor
let shadowColor: UIColor
switch style {
case .light:
self.preferredStatusBarStyleForBanner = .darkContent
textColor = .black
shadowColor = .white
case .dark:
self.preferredStatusBarStyleForBanner = .lightContent
textColor = .white
shadowColor = .black
default:
self.preferredStatusBarStyleForBanner = .default
textColor = .white
shadowColor = .black
}
self.profileHeaderViewController.profileBannerView.nameLabel.textColor = textColor
self.profileHeaderViewController.profileBannerView.usernameLabel.textColor = textColor
self.profileHeaderViewController.profileBannerView.nameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2)
self.profileHeaderViewController.profileBannerView.usernameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2)
}
.store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.avatarImageURL.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
@ -325,147 +270,100 @@ extension ProfileViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] avatarImageURL, _ in
guard let self = self else { return }
self.profileHeaderViewController.profileBannerView.configure(
with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL)
self.profileHeaderViewController.profileHeaderView.configure(
with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2)
)
}
.store(in: &disposeBag)
// viewModel.protected
// .map { $0 != true }
// .assign(to: \.isHidden, on: profileHeaderViewController.profileBannerView.lockImageView)
// .store(in: &disposeBag)
viewModel.name
.map { $0 ?? " " }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: profileHeaderViewController.profileBannerView.nameLabel)
.assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel)
.store(in: &disposeBag)
viewModel.username
.map { username in username.flatMap { "@" + $0 } ?? " " }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: profileHeaderViewController.profileBannerView.usernameLabel)
.assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel)
.store(in: &disposeBag)
// viewModel.friendship
// .sink { [weak self] friendship in
// guard let self = self else { return }
// let followingButton = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followActionButton
// followingButton.isHidden = friendship == nil
//
// if let friendship = friendship {
// switch friendship {
// case .following: followingButton.style = .following
// case .pending: followingButton.style = .pending
// case .none: followingButton.style = .follow
// }
// }
// }
// .store(in: &disposeBag)
// viewModel.followedBy
// .sink { [weak self] followedBy in
// guard let self = self else { return }
// let followStatusLabel = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followStatusLabel
// followStatusLabel.isHidden = followedBy != true
// }
// .store(in: &disposeBag)
//
viewModel.relationshipActionOptionSet
.receive(on: DispatchQueue.main)
.sink { [weak self] relationshipActionOptionSet in
guard let self = self else { return }
guard let mastodonUser = self.viewModel.mastodonUser.value else {
self.moreMenuBarButtonItem.menu = nil
return
}
let isMuting = relationshipActionOptionSet.contains(.muting)
let isBlocking = relationshipActionOptionSet.contains(.blocking)
self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self)
}
.store(in: &disposeBag)
viewModel.isRelationshipActionButtonHidden
.receive(on: DispatchQueue.main)
.sink { [weak self] isHidden in
guard let self = self else { return }
self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden
}
.store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.relationshipActionOptionSet.eraseToAnyPublisher(),
viewModel.isEditing.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] relationshipActionSet, isEditing in
guard let self = self else { return }
let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton
if relationshipActionSet.contains(.edit) {
friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit)
} else {
friendshipButton.configure(actionOptionSet: relationshipActionSet)
}
}
.store(in: &disposeBag)
viewModel.bioDescription
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] bio in
guard let self = self else { return }
self.profileHeaderViewController.profileBannerView.bioActiveLabel.configure(note: bio ?? "")
self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "")
})
.store(in: &disposeBag)
// Publishers.CombineLatest(
// viewModel.url.eraseToAnyPublisher(),
// viewModel.suspended.eraseToAnyPublisher()
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] url, isSuspended in
// guard let self = self else { return }
// let url = url.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " "
// self.profileHeaderViewController.profileBannerView.linkButton.setTitle(url, for: .normal)
// let isEmpty = url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
// self.profileHeaderViewController.profileBannerView.linkContainer.isHidden = isEmpty || isSuspended
// }
// .store(in: &disposeBag)
// Publishers.CombineLatest(
// viewModel.location.eraseToAnyPublisher(),
// viewModel.suspended.eraseToAnyPublisher()
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] location, isSuspended in
// guard let self = self else { return }
// let location = location.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " "
// self.profileHeaderViewController.profileBannerView.geoButton.setTitle(location, for: .normal)
// let isEmpty = location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
// self.profileHeaderViewController.profileBannerView.geoContainer.isHidden = isEmpty || isSuspended
// }
// .store(in: &disposeBag)
viewModel.statusesCount
.sink { [weak self] count in
guard let self = self else { return }
let text = count.flatMap { String($0) } ?? "-"
self.profileHeaderViewController.profileBannerView.statusDashboardView.postDashboardMeterView.numberLabel.text = text
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text
}
.store(in: &disposeBag)
viewModel.followingCount
.sink { [weak self] count in
guard let self = self else { return }
let text = count.flatMap { String($0) } ?? "-"
self.profileHeaderViewController.profileBannerView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text
}
.store(in: &disposeBag)
viewModel.followersCount
.sink { [weak self] count in
guard let self = self else { return }
let text = count.flatMap { String($0) } ?? "-"
self.profileHeaderViewController.profileBannerView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text
}
.store(in: &disposeBag)
// viewModel.followersCount
// .sink { [weak self] count in
// guard let self = self else { return }
// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.followersStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-"
// }
// .store(in: &disposeBag)
// viewModel.listedCount
// .sink { [weak self] count in
// guard let self = self else { return }
// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.listedStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-"
// }
// .store(in: &disposeBag)
// viewModel.suspended
// .receive(on: DispatchQueue.main)
// .sink { [weak self] isSuspended in
// guard let self = self else { return }
// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.isHidden = isSuspended
// self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.isHidden = isSuspended
// if isSuspended {
// self.profileSegmentedViewController
// .pagingViewController.viewModel
// .profileTweetPostTimelineViewController.viewModel
// .stateMachine
// .enter(UserTimelineViewModel.State.Suspended.self)
// self.profileSegmentedViewController
// .pagingViewController.viewModel
// .profileMediaPostTimelineViewController.viewModel
// .stateMachine
// .enter(UserMediaTimelineViewModel.State.Suspended.self)
// self.profileSegmentedViewController
// .pagingViewController.viewModel
// .profileLikesPostTimelineViewController.viewModel
// .stateMachine
// .enter(UserLikeTimelineViewModel.State.Suspended.self)
// }
// }
// .store(in: &disposeBag)
//
profileHeaderViewController.profileBannerView.delegate = self
profileHeaderViewController.profileHeaderView.delegate = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// set back button tint color in SceneCoordinator.present(scene:from:transition:)
// force layout to make banner image tweak take effect
view.layoutIfNeeded()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.send()
// set overlay scroll view initial content size
@ -482,6 +380,27 @@ extension ProfileViewController {
}
extension ProfileViewController {
private func bind(userTimelineViewModel: UserTimelineViewModel) {
viewModel.domain.assign(to: \.value, on: userTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag)
viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag)
viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag)
}
}
extension ProfileViewController {
@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 }
let composeViewModel = ComposeViewModel(
context: context,
composeKind: .mention(mastodonUserObjectID: mastodonUser.objectID)
)
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -500,43 +419,6 @@ extension ProfileViewController {
// 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))
// }
//
// @objc private func unmuteBarButtonItemPressed(_ sender: UIBarButtonItem) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// guard let twitterUser = viewModel.twitterUser.value else {
// assertionFailure()
// return
// }
//
// UserProviderFacade.toggleMuteUser(
// context: context,
// twitterUser: twitterUser,
// muted: viewModel.muted.value
// )
// .sink { _ in
// // do nothing
// } receiveValue: { _ in
// // do nothing
// }
// .store(in: &disposeBag)
// }
//
// @objc private func moreMenuBarButtonItemPressed(_ sender: UIBarButtonItem) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// guard let twitterUser = viewModel.twitterUser.value else {
// assertionFailure()
// return
// }
//
// let moreMenuAlertController = UserProviderFacade.createMoreMenuAlertControllerForUser(
// twitterUser: twitterUser,
// muted: viewModel.muted.value,
// blocked: viewModel.blocked.value,
// sender: sender,
// dependency: self
// )
// present(moreMenuAlertController, animated: true, completion: nil)
// }
}
@ -600,64 +482,99 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate {
// setup observer and gesture fallback
currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView)
postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer)
// if let userMediaTimelineViewController = postTimelineViewController as? UserMediaTimelineViewController,
// let currentState = userMediaTimelineViewController.viewModel.stateMachine.currentState {
// switch currentState {
// case is UserMediaTimelineViewModel.State.NoMore,
// is UserMediaTimelineViewModel.State.NotAuthorized,
// is UserMediaTimelineViewModel.State.Blocked:
// break
// default:
// if userMediaTimelineViewController.viewModel.items.value.isEmpty {
// userMediaTimelineViewController.viewModel.stateMachine.enter(UserMediaTimelineViewModel.State.Reloading.self)
// }
// }
// }
//
// if let userLikeTimelineViewController = postTimelineViewController as? UserLikeTimelineViewController,
// let currentState = userLikeTimelineViewController.viewModel.stateMachine.currentState {
// switch currentState {
// case is UserLikeTimelineViewModel.State.NoMore,
// is UserLikeTimelineViewModel.State.NotAuthorized,
// is UserLikeTimelineViewModel.State.Blocked:
// break
// default:
// if userLikeTimelineViewController.viewModel.items.value.isEmpty {
// userLikeTimelineViewController.viewModel.stateMachine.enter(UserLikeTimelineViewModel.State.Reloading.self)
// }
// }
// }
}
}
// MARK: - ProfileBannerInfoActionViewDelegate
//extension ProfileViewController: ProfileBannerInfoActionViewDelegate {
//
// func profileBannerInfoActionView(_ profileBannerInfoActionView: ProfileBannerInfoActionView, followActionButtonPressed button: FollowActionButton) {
// UserProviderFacade
// .toggleUserFriendship(provider: self, sender: button)
// .sink { _ in
// // do nothing
// } receiveValue: { _ in
// // do nothing
// }
// .store(in: &disposeBag)
// }
//
//}
// MARK: - ProfileHeaderViewDelegate
extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
let relationshipActionSet = viewModel.relationshipActionOptionSet.value
if relationshipActionSet.contains(.edit) {
viewModel.isEditing.value.toggle()
} else {
guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return }
switch relationshipAction {
case .none:
break
case .follow, .following:
UserProviderFacade.toggleUserFollowRelationship(provider: self)
.sink { _ in
} receiveValue: { _ in
}
.store(in: &disposeBag)
case .pending:
break
case .muting:
guard let mastodonUser = viewModel.mastodonUser.value else { return }
let name = mastodonUser.displayNameWithFallback
let alertController = UIAlertController(
title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title,
message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name),
preferredStyle: .alert
)
let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in
guard let self = self else { return }
UserProviderFacade.toggleUserMuteRelationship(provider: self)
.sink { _ in
// do nothing
} receiveValue: { _ in
// do nothing
}
.store(in: &self.context.disposeBag)
}
alertController.addAction(unmuteAction)
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
case .blocking:
guard let mastodonUser = viewModel.mastodonUser.value else { return }
let name = mastodonUser.displayNameWithFallback
let alertController = UIAlertController(
title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title,
message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name),
preferredStyle: .alert
)
let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in
guard let self = self else { return }
UserProviderFacade.toggleUserBlockRelationship(provider: self)
.sink { _ in
// do nothing
} receiveValue: { _ in
// do nothing
}
.store(in: &self.context.disposeBag)
}
alertController.addAction(unblockAction)
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
case .blocked:
break
default:
assertionFailure()
}
}
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) {
switch entity.type {
case .url(_, _, let url, _):
guard let url = URL(string: url) else { return }
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
default:
// TODO:
break
}
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) {

View File

@ -26,14 +26,12 @@ class ProfileViewModel: NSObject {
let mastodonUser: CurrentValueSubject<MastodonUser?, Never>
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
let viewDidAppear = PassthroughSubject<Void, Never>()
let headerDomainLumaStyle = CurrentValueSubject<UIUserInterfaceStyle, Never>(.dark) // default dark for placeholder banner
// output
let domain: CurrentValueSubject<String?, Never>
let userID: CurrentValueSubject<UserID?, Never>
let bannerImageURL: CurrentValueSubject<URL?, Never>
let avatarImageURL: CurrentValueSubject<URL?, Never>
// let protected: CurrentValueSubject<Bool?, Never>
let name: CurrentValueSubject<String?, Never>
let username: CurrentValueSubject<String?, Never>
let bioDescription: CurrentValueSubject<String?, Never>
@ -42,13 +40,19 @@ class ProfileViewModel: NSObject {
let followingCount: CurrentValueSubject<Int?, Never>
let followersCount: CurrentValueSubject<Int?, Never>
// let friendship: CurrentValueSubject<Friendship?, Never>
// let followedBy: CurrentValueSubject<Bool?, Never>
// let muted: CurrentValueSubject<Bool, Never>
// let blocked: CurrentValueSubject<Bool, Never>
//
// let suspended = CurrentValueSubject<Bool, Never>(false)
//
let protected: CurrentValueSubject<Bool?, Never>
// let suspended: CurrentValueSubject<Bool, Never>
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)
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
let isRelationshipActionButtonHidden = CurrentValueSubject<Bool, Never>(true)
let isReplyBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
let isMoreMenuBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
self.context = context
@ -65,11 +69,14 @@ class ProfileViewModel: NSObject {
self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) })
self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) })
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
// self.friendship = CurrentValueSubject(nil)
// self.followedBy = CurrentValueSubject(nil)
// self.muted = CurrentValueSubject(false)
// self.blocked = CurrentValueSubject(false)
self.protected = CurrentValueSubject(mastodonUser?.locked)
super.init()
relationshipActionOptionSet
.compactMap { $0.highPriorityAction(except: []) }
.map { $0 == .none }
.assign(to: \.value, on: isRelationshipActionButtonHidden)
.store(in: &disposeBag)
// bind active authentication
context.authenticationService.activeMastodonAuthentication
@ -84,26 +91,54 @@ class ProfileViewModel: NSObject {
self.currentMastodonUser.value = activeMastodonAuthentication.user
}
.store(in: &disposeBag)
setup()
}
}
extension ProfileViewModel {
enum Friendship: CustomDebugStringConvertible {
case following
case pending
case none
var debugDescription: String {
switch self {
case .following: return "following"
case .pending: return "pending"
case .none: return "none"
// query relationship
let mastodonUserID = self.mastodonUser.map { $0?.id }
let pendingRetryPublisher = CurrentValueSubject<TimeInterval, Never>(1)
Publishers.CombineLatest3(
mastodonUserID.removeDuplicates().eraseToAnyPublisher(),
context.authenticationService.activeMastodonAuthenticationBox.eraseToAnyPublisher(),
pendingRetryPublisher.eraseToAnyPublisher()
)
.compactMap { mastodonUserID, activeMastodonAuthenticationBox, _ -> (String, AuthenticationService.MastodonAuthenticationBox)? in
guard let mastodonUserID = mastodonUserID, let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return nil }
guard mastodonUserID != activeMastodonAuthenticationBox.userID else { return nil }
return (mastodonUserID, activeMastodonAuthenticationBox)
}
.setFailureType(to: Error.self) // allow failure
.flatMap { mastodonUserID, activeMastodonAuthenticationBox -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> in
let domain = activeMastodonAuthenticationBox.domain
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch for user %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUserID)
return self.context.apiService.relationship(domain: domain, accountIDs: [mastodonUserID], authorizationBox: activeMastodonAuthenticationBox)
//.retry(3)
.eraseToAnyPublisher()
}
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update success", ((#file as NSString).lastPathComponent), #line, #function)
// there are seconds delay after request follow before requested -> following. Query again when needs
guard let relationship = response.value.first else { return }
if relationship.requested == true {
let delay = pendingRetryPublisher.value
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let _ = self else { return }
pendingRetryPublisher.value = min(2 * delay, 60)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function)
}
}
}
.store(in: &disposeBag)
setup()
}
}
@ -117,9 +152,11 @@ extension ProfileViewModel {
.receive(on: DispatchQueue.main)
.sink { [weak self] mastodonUser, currentMastodonUser in
guard let self = self else { return }
// Update view model attribute
self.update(mastodonUser: mastodonUser)
self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
// Setup observer for user
if let mastodonUser = mastodonUser {
// setup observer
self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser)
@ -147,6 +184,7 @@ extension ProfileViewModel {
self.mastodonUserObserver = nil
}
// Setup observer for user
if let currentMastodonUser = currentMastodonUser {
// setup observer
self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser)
@ -179,7 +217,6 @@ extension ProfileViewModel {
self.userID.value = mastodonUser?.id
self.bannerImageURL.value = mastodonUser?.headerImageURL()
self.avatarImageURL.value = mastodonUser?.avatarImageURL()
// self.protected.value = twitterUser?.protected
self.name.value = mastodonUser?.displayNameWithFallback
self.username.value = mastodonUser?.acctWithDomain
self.bioDescription.value = mastodonUser?.note
@ -187,11 +224,159 @@ extension ProfileViewModel {
self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) }
self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) }
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
self.protected.value = mastodonUser?.locked
}
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
// TODO:
guard let mastodonUser = mastodonUser,
let currentMastodonUser = currentMastodonUser else {
// set relationship
self.relationshipActionOptionSet.value = .none
self.isFollowedBy.value = false
self.isMuting.value = false
self.isBlocking.value = false
self.isBlockedBy.value = false
// set bar button item state
self.isReplyBarButtonItemHidden.value = true
self.isMoreMenuBarButtonItemHidden.value = true
return
}
if mastodonUser == currentMastodonUser {
self.relationshipActionOptionSet.value = [.edit]
// set bar button item state
self.isReplyBarButtonItemHidden.value = true
self.isMoreMenuBarButtonItemHidden.value = true
} else {
// set with follow action default
var relationshipActionSet = RelationshipActionOptionSet([.follow])
let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isFollowing {
relationshipActionSet.insert(.following)
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description)
let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isPending {
relationshipActionSet.insert(.pending)
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description)
let isFollowedBy = currentMastodonUser.followingBy.flatMap { $0.contains(mastodonUser) } ?? false
self.isFollowedBy.value = isFollowedBy
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description)
let isMuting = mastodonUser.mutingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isMuting {
relationshipActionSet.insert(.muting)
}
self.isMuting.value = isMuting
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description)
let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isBlocking {
relationshipActionSet.insert(.blocking)
}
self.isBlocking.value = isBlocking
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description)
let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false
if isBlockedBy {
relationshipActionSet.insert(.blocked)
}
self.isBlockedBy.value = isBlockedBy
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description)
self.relationshipActionOptionSet.value = relationshipActionSet
// set bar button item state
self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy
self.isMoreMenuBarButtonItemHidden.value = false
}
}
}
extension ProfileViewModel {
enum RelationshipAction: Int, CaseIterable {
case none // set hide from UI
case follow
case pending
case following
case muting
case blocked
case blocking
case edit
case editing
var option: RelationshipActionOptionSet {
return RelationshipActionOptionSet(rawValue: 1 << rawValue)
}
}
// construct option set on the enum for safe iterator
struct RelationshipActionOptionSet: OptionSet {
let rawValue: Int
static let none = RelationshipAction.none.option
static let follow = RelationshipAction.follow.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 edit = RelationshipAction.edit.option
static let editing = RelationshipAction.editing.option
static let editOptions: RelationshipActionOptionSet = [.edit, .editing]
func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? {
let set = subtracting(except)
for action in RelationshipAction.allCases.reversed() where set.contains(action.option) {
return action
}
return nil
}
var title: String {
guard let highPriorityAction = self.highPriorityAction(except: []) else {
assertionFailure()
return " "
}
switch highPriorityAction {
case .none: return " "
case .follow: return L10n.Common.Controls.Firendship.follow
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 .edit: return L10n.Common.Controls.Firendship.editInfo
case .editing: return L10n.Common.Controls.Actions.done
}
}
var backgroundColor: UIColor {
guard let highPriorityAction = self.highPriorityAction(except: []) else {
assertionFailure()
return Asset.Colors.Button.normal.color
}
switch highPriorityAction {
case .none: return Asset.Colors.Button.normal.color
case .follow: 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 .blocking: return Asset.Colors.Background.danger.color
case .edit: return Asset.Colors.Button.normal.color
case .editing: return Asset.Colors.Button.normal.color
}
}
}
}

View File

@ -0,0 +1,54 @@
//
// RemoteProfileViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-2.
//
import os.log
import Foundation
import CoreDataStack
import MastodonSDK
final class RemoteProfileViewModel: ProfileViewModel {
convenience init(context: AppContext, userID: Mastodon.Entity.Account.ID) {
self.init(context: context, optionalMastodonUser: nil)
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
let domain = activeMastodonAuthenticationBox.domain
let authorization = activeMastodonAuthenticationBox.userAuthorization
context.apiService.accountInfo(
domain: domain,
userID: userID,
authorization: authorization
)
.retry(3)
.sink { completion in
switch completion {
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote user %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, userID, error.localizedDescription)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote user %s fetched", ((#file as NSString).lastPathComponent), #line, #function, userID)
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
let managedObjectContext = context.managedObjectContext
let request = MastodonUser.sortedFetchRequest
request.fetchLimit = 1
request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id)
guard let mastodonUser = managedObjectContext.safeFetch(request).first else {
assertionFailure()
return
}
self.mastodonUser.value = mastodonUser
}
.store(in: &disposeBag)
}
}

View File

@ -27,6 +27,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency {
let tableView = UITableView()
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.register(TimelineHeaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineHeaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
@ -100,9 +101,29 @@ extension UserTimelineViewController {
// MARK: - UITableViewDelegate
extension UserTimelineViewController: UITableViewDelegate {
// TODO: cache cell height
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return 200
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
if case .bottomLoader = item {
return TimelineLoaderTableViewCell.cellHeight
} else {
return 200
}
}
// os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
return ceil(frame.height)
}
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))
}
}

View File

@ -31,8 +31,6 @@ extension UserTimelineViewModel.State {
switch stateClass {
case is Reloading.Type:
return viewModel.userID.value != nil
case is Suspended.Type:
return true
default:
return false
}
@ -48,10 +46,6 @@ extension UserTimelineViewModel.State {
return true
case is NoMore.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -116,8 +110,6 @@ extension UserTimelineViewModel.State {
switch stateClass {
case is Reloading.Type, is LoadingMore.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -129,8 +121,6 @@ extension UserTimelineViewModel.State {
switch stateClass {
case is Reloading.Type, is LoadingMore.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -146,10 +136,6 @@ extension UserTimelineViewModel.State {
return true
case is NoMore.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -188,7 +174,12 @@ extension UserTimelineViewModel.State {
)
.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)
case .finished:
break
}
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -210,50 +201,12 @@ extension UserTimelineViewModel.State {
.store(in: &viewModel.disposeBag)
}
}
class NotAuthorized: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
class Blocked: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
class Suspended: UserTimelineViewModel.State {
}
class NoMore: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -261,8 +214,9 @@ extension UserTimelineViewModel.State {
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let viewModel = viewModel, let _ = stateMachine else { return }
// trigger data source update
viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value
}
}

View File

@ -24,6 +24,10 @@ class UserTimelineViewModel: NSObject {
let userID: CurrentValueSubject<String?, Never>
let queryFilter: CurrentValueSubject<QueryFilter, Never>
let statusFetchedResultsController: StatusFetchedResultsController
var cellFrameCache = NSCache<NSNumber, NSValue>()
let isBlocking = CurrentValueSubject<Bool, Never>(false)
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
@ -34,9 +38,6 @@ class UserTimelineViewModel: NSObject {
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.LoadingMore(viewModel: self),
State.NotAuthorized(viewModel: self),
State.Blocked(viewModel: self),
State.Suspended(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
@ -59,46 +60,64 @@ class UserTimelineViewModel: NSObject {
.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 isPermissionDenied = false
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
}
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
var items: [Item] = []
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.LoadingMore, is State.Idle, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
// TODO: handle other states
default:
break
}
}
Publishers.CombineLatest3(
statusFetchedResultsController.objectIDs.eraseToAnyPublisher(),
isBlocking.eraseToAnyPublisher(),
isBlockedBy.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] objectIDs, isBlocking, isBlockedBy 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)
}
.store(in: &disposeBag)
guard !isBlocking else {
snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocking))], toSection: .main)
return
}
guard !isBlockedBy else {
snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocked))], toSection: .main)
return
}
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.LoadingMore, 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)
}
deinit {
@ -125,3 +144,4 @@ extension UserTimelineViewModel {
}
}

View File

@ -70,7 +70,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
extension SearchRecommendAccountsCollectionViewCell {
private func configure() {
headerImageView.backgroundColor = Asset.Colors.buttonDefault.color
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
layer.cornerRadius = 8
clipsToBounds = true

View File

@ -59,7 +59,7 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
extension SearchRecommendTagsCollectionViewCell {
private func configure() {
backgroundColor = Asset.Colors.buttonDefault.color
backgroundColor = Asset.Colors.brandBlue.color
layer.cornerRadius = 8
clipsToBounds = true

View File

@ -19,7 +19,7 @@ final class SearchViewController: UIViewController, NeedsDependency {
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder
searchBar.tintColor = Asset.Colors.buttonDefault.color
searchBar.tintColor = Asset.Colors.brandBlue.color
searchBar.translatesAutoresizingMaskIntoConstraints = false
let micImage = UIImage(systemName: "mic.fill")
searchBar.setImage(micImage, for: .bookmark, state: .normal)

View File

@ -18,7 +18,7 @@ class SearchRecommendCollectionHeader: UIView {
let descriptionLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightSecondaryText.color
label.textColor = Asset.Colors.Label.secondary.color
label.font = .preferredFont(forTextStyle: .body)
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
@ -27,7 +27,7 @@ class SearchRecommendCollectionHeader: UIView {
let seeAllButton: UIButton = {
let button = UIButton(type: .custom)
button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal)
return button
}()

View File

@ -13,7 +13,7 @@ class NavigationBarProgressView: UIView {
let sliderView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.buttonDefault.color
view.backgroundColor = Asset.Colors.brandBlue.color
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()

View File

@ -17,7 +17,7 @@ protocol StatusViewDelegate: class {
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity)
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
}
final class StatusView: UIView {
@ -403,8 +403,8 @@ extension StatusView {
statusContentWarningContainerStackView.isHidden = true
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
playerContainerView.delegate = self
activeTextLabel.delegate = self
playerContainerView.delegate = self
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
headerInfoLabel.isUserInteractionEnabled = true
@ -477,6 +477,14 @@ extension StatusView {
}
// MARK: - ActiveLabelDelegate
extension StatusView: ActiveLabelDelegate {
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select entity: %s", ((#file as NSString).lastPathComponent), #line, #function, entity.primaryText)
delegate?.statusView(self, activeLabel: activeLabel, didSelectActiveEntity: entity)
}
}
// MARK: - PlayerContainerViewDelegate
extension StatusView: PlayerContainerViewDelegate {
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
@ -493,13 +501,6 @@ extension StatusView: AvatarConfigurableView {
var configurableVerifiedBadgeImageView: UIImageView? { nil }
}
// MARK: - ActiveLabelDelegate
extension StatusView: ActiveLabelDelegate {
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
delegate?.statusView(self, didSelectActiveEntity: activeLabel, entity: entity)
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI

View File

@ -0,0 +1,122 @@
//
// TimelineHeaderView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-6.
//
final class TimelineHeaderView: UIView {
let iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.tintColor = Asset.Colors.Label.secondary.color
return imageView
}()
let messageLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 17)
label.textAlignment = .center
label.textColor = Asset.Colors.Label.secondary.color
label.text = "info"
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension TimelineHeaderView {
private func _init() {
backgroundColor = .clear
let topPaddingView = UIView()
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(topPaddingView)
NSLayoutConstraint.activate([
topPaddingView.topAnchor.constraint(equalTo: topAnchor),
topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.alignment = .center
containerStackView.distribution = .fill
containerStackView.spacing = 16
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
containerStackView.addArrangedSubview(iconImageView)
containerStackView.addArrangedSubview(messageLabel)
let bottomPaddingView = UIView()
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomPaddingView)
NSLayoutConstraint.activate([
bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor),
bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
NSLayoutConstraint.activate([
topPaddingView.heightAnchor.constraint(equalToConstant: 100).priority(.defaultHigh),
bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0),
])
}
}
extension Item.EmptyStateHeaderAttribute.Reason {
var iconImage: UIImage? {
switch self {
case .noStatusFound, .blocking, .blocked, .suspended:
return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))!
}
}
var message: String {
switch self {
case .noStatusFound:
return L10n.Common.Controls.Timeline.Header.noStatusFound
case .blocking:
return L10n.Common.Controls.Timeline.Header.blockingWarning
case .blocked:
return L10n.Common.Controls.Timeline.Header.blockedWarning
case .suspended:
return L10n.Common.Controls.Timeline.Header.suspendedWarning
}
}
}
#if DEBUG && canImport(SwiftUI)
import SwiftUI
struct TimelineHeaderView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let headerView = TimelineHeaderView()
headerView.iconImageView.image = Item.EmptyStateHeaderAttribute.Reason.blocking.iconImage
headerView.messageLabel.text = Item.EmptyStateHeaderAttribute.Reason.blocking.message
return headerView
}
.previewLayout(.fixed(width: 375, height: 400))
}
}
}
#endif

View File

@ -19,21 +19,23 @@ protocol StatusTableViewCellDelegate: class {
func parent() -> UIViewController
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get }
func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController)
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity)
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath)
}
extension StatusTableViewCellDelegate {
@ -219,10 +221,10 @@ extension StatusTableViewCell: StatusViewDelegate {
delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button)
}
func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) {
delegate?.statusTableViewCell(self, statusView: statusView, didSelectActiveEntity: entity)
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity)
}
}
// MARK: - MosaicImageViewDelegate

View File

@ -0,0 +1,42 @@
//
// TimelineHeaderTableViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-6.
//
import UIKit
final class TimelineHeaderTableViewCell: UITableViewCell {
let timelineHeaderView = TimelineHeaderView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension TimelineHeaderTableViewCell {
private func _init() {
selectionStyle = .none
backgroundColor = .clear
timelineHeaderView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(timelineHeaderView)
NSLayoutConstraint.activate([
timelineHeaderView.topAnchor.constraint(equalTo: contentView.topAnchor),
timelineHeaderView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
timelineHeaderView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
timelineHeaderView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}
}

View File

@ -67,7 +67,7 @@ class TimelineLoaderTableViewCell: UITableViewCell {
func stopAnimating() {
activityIndicatorView.stopAnimating()
self.loadMoreButton.isEnabled = true
self.loadMoreLabel.textColor = Asset.Colors.buttonDefault.color
self.loadMoreLabel.textColor = Asset.Colors.brandBlue.color
self.loadMoreLabel.text = ""
}

View File

@ -163,7 +163,7 @@ extension ActionToolbarContainer {
}
private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) {
let tintColor = isHighlight ? Asset.Colors.systemGreen.color : Asset.Colors.Button.actionToolbar.color
let tintColor = isHighlight ? Asset.Colors.successGreen.color : Asset.Colors.Button.actionToolbar.color
reblogButton.tintColor = tintColor
reblogButton.setTitleColor(tintColor, for: .normal)
reblogButton.setTitleColor(tintColor, for: .highlighted)

View File

@ -10,6 +10,52 @@ import Combine
import CommonOSLog
import MastodonSDK
extension APIService {
func accountInfo(
domain: String,
userID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
return Mastodon.API.Account.accountInfo(
session: session,
domain: domain,
userID: userID,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
let log = OSLog.api
let account = response.value
return self.backgroundManagedObjectContext.performChanges {
let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser(
into: self.backgroundManagedObjectContext,
for: nil,
in: domain,
entity: account,
userCache: nil,
networkDate: response.networkDate,
log: log
)
let flag = isCreated ? "+" : "-"
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username)
}
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Account> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
extension APIService {
func accountVerifyCredentials(
@ -33,12 +79,20 @@ extension APIService {
entity: account,
userCache: nil,
networkDate: response.networkDate,
log: log)
log: log
)
let flag = isCreated ? "+" : "-"
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username)
}
.setFailureType(to: Error.self)
.map { _ in return response }
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Account> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
@ -72,7 +126,14 @@ extension APIService {
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username)
}
.setFailureType(to: Error.self)
.map { _ in return response }
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Account> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()

View File

@ -0,0 +1,168 @@
//
// APIService+Block.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-2.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService {
func toggleBlock(
for mastodonUser: MastodonUser,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
return blockUpdateLocal(
mastodonUserObjectID: mastodonUser.objectID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.receive(on: DispatchQueue.main)
.handleEvents { _ in
impactFeedbackGenerator.prepare()
} receiveOutput: { _ in
impactFeedbackGenerator.impactOccurred()
} receiveCompletion: { completion in
switch completion {
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
assertionFailure(error.localizedDescription)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function)
break
}
}
.flatMap { blockQueryType, mastodonUserID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in
return self.blockUpdateRemote(
blockQueryType: blockQueryType,
mastodonUserID: mastodonUserID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.receive(on: DispatchQueue.main)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
// TODO: handle error
// rollback
self.blockUpdateLocal(
mastodonUserObjectID: mastodonUser.objectID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.sink { completion in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function)
} receiveValue: { _ in
// do nothing
notificationFeedbackGenerator.prepare()
notificationFeedbackGenerator.notificationOccurred(.error)
}
.store(in: &self.disposeBag)
case .finished:
notificationFeedbackGenerator.notificationOccurred(.success)
os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function)
}
})
.eraseToAnyPublisher()
}
}
extension APIService {
// update database local and return block query update type for remote request
func blockUpdateLocal(
mastodonUserObjectID: NSManagedObjectID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<(Mastodon.API.Account.BlockQueryType, MastodonUser.ID), Error> {
let domain = mastodonAuthenticationBox.domain
let requestMastodonUserID = mastodonAuthenticationBox.userID
var _targetMastodonUserID: MastodonUser.ID?
var _queryType: Mastodon.API.Account.BlockQueryType?
let managedObjectContext = backgroundManagedObjectContext
return managedObjectContext.performChanges {
let request = MastodonUser.sortedFetchRequest
request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else {
assertionFailure()
return
}
let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser
_targetMastodonUserID = mastodonUser.id
let isBlocking = (mastodonUser.blockingBy ?? Set()).contains(_requestMastodonUser)
_queryType = isBlocking ? .unblock : .block
mastodonUser.update(isBlocking: !isBlocking, by: _requestMastodonUser)
}
.tryMap { result in
switch result {
case .success:
guard let targetMastodonUserID = _targetMastodonUserID,
let queryType = _queryType else {
throw APIError.implicit(.badRequest)
}
return (queryType, targetMastodonUserID)
case .failure(let error):
assertionFailure(error.localizedDescription)
throw error
}
}
.eraseToAnyPublisher()
}
func blockUpdateRemote(
blockQueryType: Mastodon.API.Account.BlockQueryType,
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Account.block(
session: session,
domain: domain,
accountID: mastodonUserID,
blockQueryType: blockQueryType,
authorization: authorization
)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let _ = self else { return }
switch completion {
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] block update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// TODO: update relationship
switch blockQueryType {
case .block:
break
case .unblock:
break
}
}
})
.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,188 @@
//
// APIService+Follow.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-2.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService {
/// Toggle friendship between target MastodonUser and current MastodonUser
///
/// Following / Following pending <-> Unfollow
///
/// - Parameters:
/// - mastodonUser: target MastodonUser
/// - activeMastodonAuthenticationBox: `AuthenticationService.MastodonAuthenticationBox`
/// - Returns: publisher for `Relationship`
func toggleFollow(
for mastodonUser: MastodonUser,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
return followUpdateLocal(
mastodonUserObjectID: mastodonUser.objectID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.receive(on: DispatchQueue.main)
.handleEvents { _ in
impactFeedbackGenerator.prepare()
} receiveOutput: { _ in
impactFeedbackGenerator.impactOccurred()
} receiveCompletion: { completion in
switch completion {
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
assertionFailure(error.localizedDescription)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function)
break
}
}
.flatMap { followQueryType, mastodonUserID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in
return self.followUpdateRemote(
followQueryType: followQueryType,
mastodonUserID: mastodonUserID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.receive(on: DispatchQueue.main)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
// TODO: handle error
// rollback
self.followUpdateLocal(
mastodonUserObjectID: mastodonUser.objectID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.sink { completion in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function)
} receiveValue: { _ in
// do nothing
notificationFeedbackGenerator.prepare()
notificationFeedbackGenerator.notificationOccurred(.error)
}
.store(in: &self.disposeBag)
case .finished:
notificationFeedbackGenerator.notificationOccurred(.success)
os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function)
}
})
.eraseToAnyPublisher()
}
}
extension APIService {
// update database local and return follow query update type for remote request
func followUpdateLocal(
mastodonUserObjectID: NSManagedObjectID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<(Mastodon.API.Account.FollowQueryType, MastodonUser.ID), Error> {
let domain = mastodonAuthenticationBox.domain
let requestMastodonUserID = mastodonAuthenticationBox.userID
var _targetMastodonUserID: MastodonUser.ID?
var _queryType: Mastodon.API.Account.FollowQueryType?
let managedObjectContext = backgroundManagedObjectContext
return managedObjectContext.performChanges {
let request = MastodonUser.sortedFetchRequest
request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else {
assertionFailure()
return
}
let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser
_targetMastodonUserID = mastodonUser.id
let isPending = (mastodonUser.followRequestedBy ?? Set()).contains(_requestMastodonUser)
let isFollowing = (mastodonUser.followingBy ?? Set()).contains(_requestMastodonUser)
if isFollowing || isPending {
_queryType = .unfollow
mastodonUser.update(isFollowing: false, by: _requestMastodonUser)
mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser)
} else {
_queryType = .follow(query: Mastodon.API.Account.FollowQuery())
if mastodonUser.locked {
mastodonUser.update(isFollowing: false, by: _requestMastodonUser)
mastodonUser.update(isFollowRequested: true, by: _requestMastodonUser)
} else {
mastodonUser.update(isFollowing: true, by: _requestMastodonUser)
mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser)
}
}
}
.tryMap { result in
switch result {
case .success:
guard let targetMastodonUserID = _targetMastodonUserID,
let queryType = _queryType else {
throw APIError.implicit(.badRequest)
}
return (queryType, targetMastodonUserID)
case .failure(let error):
assertionFailure(error.localizedDescription)
throw error
}
}
.eraseToAnyPublisher()
}
func followUpdateRemote(
followQueryType: Mastodon.API.Account.FollowQueryType,
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Account.follow(
session: session,
domain: domain,
accountID: mastodonUserID,
followQueryType: followQueryType,
authorization: authorization
)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let _ = self else { return }
switch completion {
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
break
case .finished:
switch followQueryType {
case .follow:
break
case .unfollow:
break
}
}
})
.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,167 @@
//
// APIService+Mute.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-2.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService {
func toggleMute(
for mastodonUser: MastodonUser,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
return muteUpdateLocal(
mastodonUserObjectID: mastodonUser.objectID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.receive(on: DispatchQueue.main)
.handleEvents { _ in
impactFeedbackGenerator.prepare()
} receiveOutput: { _ in
impactFeedbackGenerator.impactOccurred()
} receiveCompletion: { completion in
switch completion {
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
assertionFailure(error.localizedDescription)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function)
break
}
}
.flatMap { muteQueryType, mastodonUserID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in
return self.muteUpdateRemote(
muteQueryType: muteQueryType,
mastodonUserID: mastodonUserID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.receive(on: DispatchQueue.main)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
// TODO: handle error
// rollback
self.muteUpdateLocal(
mastodonUserObjectID: mastodonUser.objectID,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.sink { completion in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function)
} receiveValue: { _ in
// do nothing
notificationFeedbackGenerator.prepare()
notificationFeedbackGenerator.notificationOccurred(.error)
}
.store(in: &self.disposeBag)
case .finished:
notificationFeedbackGenerator.notificationOccurred(.success)
os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function)
}
})
.eraseToAnyPublisher()
}
}
extension APIService {
// update database local and return mute query update type for remote request
func muteUpdateLocal(
mastodonUserObjectID: NSManagedObjectID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<(Mastodon.API.Account.MuteQueryType, MastodonUser.ID), Error> {
let domain = mastodonAuthenticationBox.domain
let requestMastodonUserID = mastodonAuthenticationBox.userID
var _targetMastodonUserID: MastodonUser.ID?
var _queryType: Mastodon.API.Account.MuteQueryType?
let managedObjectContext = backgroundManagedObjectContext
return managedObjectContext.performChanges {
let request = MastodonUser.sortedFetchRequest
request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else {
assertionFailure()
return
}
let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser
_targetMastodonUserID = mastodonUser.id
let isMuting = (mastodonUser.mutingBy ?? Set()).contains(_requestMastodonUser)
_queryType = isMuting ? .unmute : .mute
mastodonUser.update(isMuting: !isMuting, by: _requestMastodonUser)
}
.tryMap { result in
switch result {
case .success:
guard let targetMastodonUserID = _targetMastodonUserID,
let queryType = _queryType else {
throw APIError.implicit(.badRequest)
}
return (queryType, targetMastodonUserID)
case .failure(let error):
assertionFailure(error.localizedDescription)
throw error
}
}
.eraseToAnyPublisher()
}
func muteUpdateRemote(
muteQueryType: Mastodon.API.Account.MuteQueryType,
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Account.mute(
session: session,
domain: domain,
accountID: mastodonUserID,
muteQueryType: muteQueryType,
authorization: authorization
)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let _ = self else { return }
switch completion {
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] Mute update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// TODO: update relationship
switch muteQueryType {
case .mute:
break
case .unmute:
break
}
}
})
.eraseToAnyPublisher()
}
}

View File

@ -5,7 +5,7 @@
// Created by MainasuK Cirno on 2021-4-1.
//
import Foundation
import UIKit
import Combine
import CoreData
import CoreDataStack
@ -19,47 +19,47 @@ extension APIService {
accountIDs: [Mastodon.Entity.Account.ID],
authorizationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> {
fatalError()
// let authorization = authorizationBox.userAuthorization
// let requestMastodonUserID = authorizationBox.userID
// let query = Mastodon.API.Account.AccountStatuseseQuery(
// maxID: maxID,
// sinceID: sinceID,
// excludeReplies: excludeReplies,
// excludeReblogs: excludeReblogs,
// onlyMedia: onlyMedia,
// limit: limit
// )
//
// return Mastodon.API.Account.statuses(
// session: session,
// domain: domain,
// accountID: accountID,
// query: query,
// authorization: authorization
// )
// .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
// return APIService.Persist.persistStatus(
// managedObjectContext: self.backgroundManagedObjectContext,
// domain: domain,
// query: nil,
// response: response,
// persistType: .user,
// requestMastodonUserID: requestMastodonUserID,
// log: OSLog.api
// )
// .setFailureType(to: Error.self)
// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in
// switch result {
// case .success:
// return response
// case .failure(let error):
// throw error
// }
// }
// .eraseToAnyPublisher()
// }
// .eraseToAnyPublisher()
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID
let query = Mastodon.API.Account.RelationshipQuery(
ids: accountIDs
)
return Mastodon.API.Account.relationships(
session: session,
domain: domain,
query: query,
authorization: authorization
)
.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, ids: accountIDs)
lookUpMastodonUserRequest.fetchLimit = accountIDs.count
let lookUpMastodonusers = managedObjectContext.safeFetch(lookUpMastodonUserRequest)
for user in lookUpMastodonusers {
guard let entity = response.value.first(where: { $0.id == user.id }) else { continue }
APIService.CoreData.update(user: user, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate)
}
}
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -111,6 +111,7 @@ extension APIService.CoreData {
networkDate: Date
) {
guard networkDate > user.updatedAt else { return }
guard entity.id != requestMastodonUser.id else { return } // not update relationship for self
user.update(isFollowing: entity.following, by: requestMastodonUser)
entity.requested.flatMap { user.update(isFollowRequested: $0, by: requestMastodonUser) }

View File

@ -67,3 +67,350 @@ extension Mastodon.API.Account {
}
}
extension Mastodon.API.Account {
public enum FollowQueryType {
case follow(query: FollowQuery)
case unfollow
}
public static func follow(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
followQueryType: FollowQueryType,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
switch followQueryType {
case .follow(let query):
return follow(session: session, domain: domain, accountID: accountID, query: query, authorization: authorization)
case .unfollow:
return unfollow(session: session, domain: domain, accountID: accountID, authorization: authorization)
}
}
}
extension Mastodon.API.Account {
static func followEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL {
let pathComponent = "accounts/" + accountID + "/follow"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Follow
///
/// Follow the given account. Can also be used to update whether to show reblogs or enable notifications.
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/1
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - accountID: id for account
/// - authorization: User token.
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func follow(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
query: FollowQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let request = Mastodon.API.post(
url: followEndpointURL(domain: domain, accountID: accountID),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
public struct FollowQuery: Codable, PostQuery {
public let reblogs: Bool?
public let notify: Bool?
public init(reblogs: Bool? = nil , notify: Bool? = nil) {
self.reblogs = reblogs
self.notify = notify
}
}
}
extension Mastodon.API.Account {
static func unfollowEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL {
let pathComponent = "accounts/" + accountID + "/unfollow"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Unfollow
///
/// Unfollow the given account.
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/1
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - accountID: id for account
/// - authorization: User token.
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func unfollow(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let request = Mastodon.API.post(
url: unfollowEndpointURL(domain: domain, accountID: accountID),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Account {
public enum BlockQueryType {
case block
case unblock
}
public static func block(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
blockQueryType: BlockQueryType,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
switch blockQueryType {
case .block:
return block(session: session, domain: domain, accountID: accountID, authorization: authorization)
case .unblock:
return unblock(session: session, domain: domain, accountID: accountID, authorization: authorization)
}
}
}
extension Mastodon.API.Account {
static func blockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL {
let pathComponent = "accounts/" + accountID + "/block"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Block
///
/// Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline).
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/1
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - accountID: id for account
/// - authorization: User token.
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func block(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let request = Mastodon.API.post(
url: blockEndpointURL(domain: domain, accountID: accountID),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Account {
static func unblockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL {
let pathComponent = "accounts/" + accountID + "/unblock"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Unblock
///
/// Unblock the given account.
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/1
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - accountID: id for account
/// - authorization: User token.
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func unblock(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let request = Mastodon.API.post(
url: unblockEndpointURL(domain: domain, accountID: accountID),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Account {
public enum MuteQueryType {
case mute
case unmute
}
public static func mute(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
muteQueryType: MuteQueryType,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
switch muteQueryType {
case .mute:
return mute(session: session, domain: domain, accountID: accountID, authorization: authorization)
case .unmute:
return unmute(session: session, domain: domain, accountID: accountID, authorization: authorization)
}
}
}
extension Mastodon.API.Account {
static func mutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL {
let pathComponent = "accounts/" + accountID + "/mute"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Mute
///
/// Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/1
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - accountID: id for account
/// - authorization: User token.
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func mute(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let request = Mastodon.API.post(
url: mutekEndpointURL(domain: domain, accountID: accountID),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Account {
static func unmutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL {
let pathComponent = "accounts/" + accountID + "/unmute"
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
/// Unmute
///
/// Unmute the given account.
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/1
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - accountID: id for account
/// - authorization: User token.
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func unmute(
session: URLSession,
domain: String,
accountID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let request = Mastodon.API.post(
url: unmutekEndpointURL(domain: domain, accountID: accountID),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}