Merge branch 'develop' into feature/searching
This commit is contained in:
commit
c7eea5d8e6
|
@ -69,6 +69,7 @@
|
||||||
<attribute name="acct" attributeType="String"/>
|
<attribute name="acct" attributeType="String"/>
|
||||||
<attribute name="avatar" attributeType="String"/>
|
<attribute name="avatar" attributeType="String"/>
|
||||||
<attribute name="avatarStatic" optional="YES" 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="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="displayName" attributeType="String"/>
|
<attribute name="displayName" attributeType="String"/>
|
||||||
<attribute name="domain" attributeType="String"/>
|
<attribute name="domain" attributeType="String"/>
|
||||||
|
@ -78,6 +79,7 @@
|
||||||
<attribute name="headerStatic" optional="YES" attributeType="String"/>
|
<attribute name="headerStatic" optional="YES" attributeType="String"/>
|
||||||
<attribute name="id" attributeType="String"/>
|
<attribute name="id" attributeType="String"/>
|
||||||
<attribute name="identifier" 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="note" optional="YES" attributeType="String"/>
|
||||||
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
|
|
@ -29,6 +29,9 @@ final public class MastodonUser: NSManagedObject {
|
||||||
@NSManaged public private(set) var followingCount: NSNumber
|
@NSManaged public private(set) var followingCount: NSNumber
|
||||||
@NSManaged public private(set) var followersCount: 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 createdAt: Date
|
||||||
@NSManaged public private(set) var updatedAt: Date
|
@NSManaged public private(set) var updatedAt: Date
|
||||||
|
|
||||||
|
@ -88,6 +91,9 @@ extension MastodonUser {
|
||||||
user.followingCount = NSNumber(value: property.followingCount)
|
user.followingCount = NSNumber(value: property.followingCount)
|
||||||
user.followersCount = NSNumber(value: property.followersCount)
|
user.followersCount = NSNumber(value: property.followersCount)
|
||||||
|
|
||||||
|
user.locked = property.locked
|
||||||
|
user.bot = property.bot ?? false
|
||||||
|
|
||||||
// Mastodon do not provide relationship on the `Account`
|
// Mastodon do not provide relationship on the `Account`
|
||||||
// Update relationship via attribute updating interface
|
// Update relationship via attribute updating interface
|
||||||
|
|
||||||
|
@ -158,6 +164,17 @@ extension MastodonUser {
|
||||||
self.followersCount = NSNumber(value: followersCount)
|
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) {
|
public func update(isFollowing: Bool, by mastodonUser: MastodonUser) {
|
||||||
if isFollowing {
|
if isFollowing {
|
||||||
if !(self.followingBy ?? Set()).contains(mastodonUser) {
|
if !(self.followingBy ?? Set()).contains(mastodonUser) {
|
||||||
|
@ -249,6 +266,8 @@ extension MastodonUser {
|
||||||
public let statusesCount: Int
|
public let statusesCount: Int
|
||||||
public let followingCount: Int
|
public let followingCount: Int
|
||||||
public let followersCount: Int
|
public let followersCount: Int
|
||||||
|
public let locked: Bool
|
||||||
|
public let bot: Bool?
|
||||||
|
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let networkDate: Date
|
public let networkDate: Date
|
||||||
|
@ -268,6 +287,8 @@ extension MastodonUser {
|
||||||
statusesCount: Int,
|
statusesCount: Int,
|
||||||
followingCount: Int,
|
followingCount: Int,
|
||||||
followersCount: Int,
|
followersCount: Int,
|
||||||
|
locked: Bool,
|
||||||
|
bot: Bool?,
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
networkDate: Date
|
networkDate: Date
|
||||||
) {
|
) {
|
||||||
|
@ -286,6 +307,8 @@ extension MastodonUser {
|
||||||
self.statusesCount = statusesCount
|
self.statusesCount = statusesCount
|
||||||
self.followingCount = followingCount
|
self.followingCount = followingCount
|
||||||
self.followersCount = followersCount
|
self.followersCount = followersCount
|
||||||
|
self.locked = locked
|
||||||
|
self.bot = bot
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.networkDate = networkDate
|
self.networkDate = networkDate
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,3 +6,15 @@ Mastodon localization template file
|
||||||
## How to contribute?
|
## 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
|
||||||
|
|
||||||
|
```
|
|
@ -69,9 +69,16 @@
|
||||||
"firendship": {
|
"firendship": {
|
||||||
"follow": "Follow",
|
"follow": "Follow",
|
||||||
"following": "Following",
|
"following": "Following",
|
||||||
|
"pending": "Pending",
|
||||||
"block": "Block",
|
"block": "Block",
|
||||||
|
"block_user": "Block %s",
|
||||||
|
"unblock": "Unblock",
|
||||||
|
"unblock_user": "Unblock %s",
|
||||||
"blocked": "Blocked",
|
"blocked": "Blocked",
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
|
"mute_user": "Mute %s",
|
||||||
|
"unmute": "Unmute",
|
||||||
|
"unmute_user": "Unmute %s",
|
||||||
"muted": "Muted",
|
"muted": "Muted",
|
||||||
"edit_info": "Edit info"
|
"edit_info": "Edit info"
|
||||||
},
|
},
|
||||||
|
@ -79,6 +86,12 @@
|
||||||
"loader": {
|
"loader": {
|
||||||
"load_missing_posts": "Load missing posts",
|
"load_missing_posts": "Load missing posts",
|
||||||
"loading_missing_posts": "Loading missing posts..."
|
"loading_missing_posts": "Loading missing posts..."
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"no_status_found": "No Status Found",
|
||||||
|
"blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.",
|
||||||
|
"blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.",
|
||||||
|
"suspended_warning": "This account is suspended."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -257,6 +270,16 @@
|
||||||
"posts": "Posts",
|
"posts": "Posts",
|
||||||
"replies": "Replies",
|
"replies": "Replies",
|
||||||
"media": "Media"
|
"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": {
|
"search": {
|
||||||
|
@ -286,6 +309,9 @@
|
||||||
"recent_search": "Recent searches",
|
"recent_search": "Recent searches",
|
||||||
"clear": "clear"
|
"clear": "clear"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"hashtag": {
|
||||||
|
"prompt": "%s people talking"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -7,6 +7,16 @@
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; };
|
||||||
|
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; };
|
||||||
|
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; };
|
||||||
|
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; };
|
||||||
|
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; };
|
||||||
|
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; };
|
||||||
|
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; };
|
||||||
|
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; };
|
||||||
|
0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; };
|
||||||
|
0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; };
|
||||||
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; };
|
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; };
|
||||||
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
|
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
|
||||||
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; };
|
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; };
|
||||||
|
@ -149,7 +159,7 @@
|
||||||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
|
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
|
||||||
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
|
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
|
||||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.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 */; };
|
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; };
|
||||||
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; };
|
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; };
|
||||||
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; };
|
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; };
|
||||||
|
@ -259,7 +269,6 @@
|
||||||
DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; };
|
DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; };
|
||||||
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; };
|
||||||
|
@ -274,6 +283,14 @@
|
||||||
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
|
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
|
||||||
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
|
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
|
||||||
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.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 */; };
|
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
|
||||||
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
|
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
|
||||||
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
|
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
|
||||||
|
@ -302,6 +319,8 @@
|
||||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
|
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
|
||||||
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
|
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
|
||||||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
|
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
|
||||||
|
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; };
|
||||||
|
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; };
|
||||||
DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; };
|
DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; };
|
||||||
DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
@ -360,6 +379,16 @@
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = "<group>"; };
|
||||||
|
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = "<group>"; };
|
||||||
|
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||||
|
0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = "<group>"; };
|
||||||
|
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = "<group>"; };
|
||||||
|
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
||||||
|
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
|
||||||
|
0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; };
|
||||||
|
0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = "<group>"; };
|
||||||
0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; };
|
0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; };
|
||||||
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
|
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
|
||||||
0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
||||||
|
@ -503,7 +532,7 @@
|
||||||
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = "<group>"; };
|
||||||
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
|
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
|
||||||
|
@ -634,6 +663,14 @@
|
||||||
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
@ -661,6 +698,8 @@
|
||||||
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = "<group>"; };
|
||||||
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = "<group>"; };
|
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = "<group>"; };
|
||||||
EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = "<group>"; };
|
EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
@ -722,6 +761,29 @@
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
0F1E2D102615C39800C38565 /* View */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */,
|
||||||
|
);
|
||||||
|
path = View;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
0F2021F5261325ED000C64BF /* HashtagTimeline */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0F1E2D102615C39800C38565 /* View */,
|
||||||
|
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */,
|
||||||
|
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
|
||||||
|
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
|
||||||
|
0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */,
|
||||||
|
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
|
||||||
|
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */,
|
||||||
|
0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */,
|
||||||
|
);
|
||||||
|
path = HashtagTimeline;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
0FAA0FDD25E0B5700017CCDE /* Welcome */ = {
|
0FAA0FDD25E0B5700017CCDE /* Welcome */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -794,6 +856,7 @@
|
||||||
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */,
|
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */,
|
||||||
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */,
|
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */,
|
||||||
DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
|
DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
|
||||||
|
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */,
|
||||||
);
|
);
|
||||||
path = Content;
|
path = Content;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -916,6 +979,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2D38F1FC25CD47D900561493 /* StatusProvider */,
|
2D38F1FC25CD47D900561493 /* StatusProvider */,
|
||||||
|
DBAE3F742615DD63004B8251 /* UserProvider */,
|
||||||
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */,
|
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */,
|
||||||
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
|
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
|
||||||
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */,
|
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */,
|
||||||
|
@ -999,6 +1063,7 @@
|
||||||
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
|
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
|
||||||
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
|
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
|
||||||
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
|
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
|
||||||
|
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */,
|
||||||
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */,
|
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */,
|
||||||
);
|
);
|
||||||
path = TableviewCell;
|
path = TableviewCell;
|
||||||
|
@ -1241,7 +1306,11 @@
|
||||||
DB9A488F26035963008B817C /* APIService+Media.swift */,
|
DB9A488F26035963008B817C /* APIService+Media.swift */,
|
||||||
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
|
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
|
||||||
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
|
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
|
||||||
|
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */,
|
||||||
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
|
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
|
||||||
|
DBAE3F932616E28B004B8251 /* APIService+Follow.swift */,
|
||||||
|
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
|
||||||
|
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
|
||||||
);
|
);
|
||||||
path = APIService;
|
path = APIService;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1456,6 +1525,7 @@
|
||||||
DB8AF55525C1379F002E6C99 /* Scene */ = {
|
DB8AF55525C1379F002E6C99 /* Scene */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0F2021F5261325ED000C64BF /* HashtagTimeline */,
|
||||||
5D03938E2612D200007FE196 /* Webview */,
|
5D03938E2612D200007FE196 /* Webview */,
|
||||||
2D7631A425C1532200929FB9 /* Share */,
|
2D7631A425C1532200929FB9 /* Share */,
|
||||||
DB8AF54E25C13703002E6C99 /* MainTab */,
|
DB8AF54E25C13703002E6C99 /* MainTab */,
|
||||||
|
@ -1500,6 +1570,7 @@
|
||||||
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
|
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
|
||||||
2D84350425FF858100EECE90 /* UIScrollView.swift */,
|
2D84350425FF858100EECE90 /* UIScrollView.swift */,
|
||||||
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
|
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
|
||||||
|
0F20223826146553000C64BF /* Array+removeDuplicates.swift */,
|
||||||
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
||||||
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
||||||
);
|
);
|
||||||
|
@ -1564,8 +1635,10 @@
|
||||||
DBB525462611ED57002F1F29 /* Header */,
|
DBB525462611ED57002F1F29 /* Header */,
|
||||||
DBB5253B2611ECF5002F1F29 /* Timeline */,
|
DBB5253B2611ECF5002F1F29 /* Timeline */,
|
||||||
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
|
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
|
||||||
|
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */,
|
||||||
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
|
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
|
||||||
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */,
|
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */,
|
||||||
|
DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */,
|
||||||
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */,
|
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = Profile;
|
path = Profile;
|
||||||
|
@ -1598,6 +1671,7 @@
|
||||||
children = (
|
children = (
|
||||||
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */,
|
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */,
|
||||||
DB35FC2E26130172006193C9 /* MastodonField.swift */,
|
DB35FC2E26130172006193C9 /* MastodonField.swift */,
|
||||||
|
DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */,
|
||||||
);
|
);
|
||||||
path = Helper;
|
path = Helper;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1619,6 +1693,15 @@
|
||||||
path = View;
|
path = View;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
DBAE3F742615DD63004B8251 /* UserProvider */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DBAE3F672615DD60004B8251 /* UserProvider.swift */,
|
||||||
|
DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */,
|
||||||
|
);
|
||||||
|
path = UserProvider;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
DBB525132611EBB1002F1F29 /* Segmented */ = {
|
DBB525132611EBB1002F1F29 /* Segmented */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1664,7 +1747,7 @@
|
||||||
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
|
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
|
||||||
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */,
|
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */,
|
||||||
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
|
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
|
||||||
DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */,
|
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
|
||||||
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
|
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
|
||||||
);
|
);
|
||||||
path = View;
|
path = View;
|
||||||
|
@ -2051,6 +2134,8 @@
|
||||||
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */,
|
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */,
|
||||||
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */,
|
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */,
|
||||||
DB98337125C9443200AD9700 /* APIService+Authentication.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 */,
|
5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */,
|
||||||
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
|
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
|
||||||
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
|
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
|
||||||
|
@ -2085,12 +2170,14 @@
|
||||||
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
|
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
|
||||||
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
|
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
|
||||||
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
|
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
|
||||||
|
0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */,
|
||||||
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
||||||
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
||||||
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
|
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
|
||||||
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
|
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
|
||||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
||||||
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
|
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
|
||||||
|
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
|
||||||
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
|
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
|
||||||
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
|
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
|
||||||
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
|
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
|
||||||
|
@ -2111,6 +2198,7 @@
|
||||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||||
2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */,
|
2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */,
|
||||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
||||||
|
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */,
|
||||||
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
|
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
|
||||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
|
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
|
||||||
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
|
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
|
||||||
|
@ -2121,6 +2209,7 @@
|
||||||
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
||||||
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
||||||
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
|
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
|
||||||
|
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
|
||||||
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
|
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
|
||||||
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
|
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
|
||||||
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
|
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
|
||||||
|
@ -2133,7 +2222,9 @@
|
||||||
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
|
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
|
||||||
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
|
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
|
||||||
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||||
|
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
|
||||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
|
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
|
||||||
|
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
|
||||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
|
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
|
||||||
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
|
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
|
||||||
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
|
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
|
||||||
|
@ -2167,17 +2258,19 @@
|
||||||
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
|
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
|
||||||
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */,
|
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */,
|
||||||
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
|
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
|
||||||
|
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */,
|
||||||
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
|
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
|
||||||
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
|
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
|
||||||
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
|
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
|
||||||
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
|
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
|
||||||
|
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||||
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
|
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
|
||||||
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
|
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
|
||||||
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
|
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
|
||||||
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
|
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
|
||||||
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
|
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
|
||||||
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
|
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
|
||||||
DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */,
|
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */,
|
||||||
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
|
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
|
||||||
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
|
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
|
||||||
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
|
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
|
||||||
|
@ -2188,7 +2281,6 @@
|
||||||
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
|
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
|
||||||
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
|
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
|
||||||
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
|
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
|
||||||
DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */,
|
|
||||||
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
|
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
|
||||||
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
|
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
|
||||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||||
|
@ -2200,6 +2292,7 @@
|
||||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
|
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
|
||||||
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
|
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
|
||||||
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
|
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
|
||||||
|
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */,
|
||||||
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */,
|
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */,
|
||||||
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */,
|
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */,
|
||||||
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
|
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
|
||||||
|
@ -2213,6 +2306,7 @@
|
||||||
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
||||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||||
|
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||||
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
||||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
||||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||||
|
@ -2225,12 +2319,14 @@
|
||||||
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */,
|
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */,
|
||||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
|
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
|
||||||
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
|
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
|
||||||
|
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
|
||||||
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
|
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
|
||||||
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
||||||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
||||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
||||||
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
||||||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
||||||
|
DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */,
|
||||||
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
||||||
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
|
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
|
||||||
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */,
|
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */,
|
||||||
|
@ -2243,6 +2339,7 @@
|
||||||
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
|
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
|
||||||
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
|
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
|
||||||
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
|
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
|
||||||
|
0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */,
|
||||||
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
||||||
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
|
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
|
||||||
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
|
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
|
||||||
|
@ -2250,7 +2347,9 @@
|
||||||
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
|
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
|
||||||
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */,
|
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */,
|
||||||
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
|
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
|
||||||
|
0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */,
|
||||||
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
|
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
|
||||||
|
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
|
||||||
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
|
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
|
||||||
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
|
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
|
||||||
2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */,
|
2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */,
|
||||||
|
@ -2260,11 +2359,13 @@
|
||||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
|
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
|
||||||
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
||||||
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
|
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
|
||||||
|
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */,
|
||||||
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
|
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
|
||||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
|
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
|
||||||
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
|
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
|
||||||
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
|
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
|
||||||
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
||||||
|
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
|
||||||
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
||||||
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
||||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
||||||
|
@ -2291,6 +2392,7 @@
|
||||||
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
|
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
|
||||||
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
|
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
|
||||||
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
|
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
|
||||||
|
DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>10</integer>
|
<integer>12</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
|
|
@ -51,11 +51,15 @@ extension SceneCoordinator {
|
||||||
// compose
|
// compose
|
||||||
case compose(viewModel: ComposeViewModel)
|
case compose(viewModel: ComposeViewModel)
|
||||||
|
|
||||||
|
// Hashtag Timeline
|
||||||
|
case hashtagTimeline(viewModel: HashtagTimelineViewModel)
|
||||||
|
|
||||||
// profile
|
// profile
|
||||||
case profile(viewModel: ProfileViewModel)
|
case profile(viewModel: ProfileViewModel)
|
||||||
|
|
||||||
// misc
|
// misc
|
||||||
case alertController(alertController: UIAlertController)
|
case alertController(alertController: UIAlertController)
|
||||||
|
case safari(url: URL)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
case publicTimeline
|
case publicTimeline
|
||||||
|
@ -111,6 +115,17 @@ extension SceneCoordinator {
|
||||||
guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else {
|
guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else {
|
||||||
return nil
|
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,
|
if let mainTabBarController = presentingViewController as? MainTabBarController,
|
||||||
let navigationController = mainTabBarController.selectedViewController as? UINavigationController,
|
let navigationController = mainTabBarController.selectedViewController as? UINavigationController,
|
||||||
|
@ -209,6 +224,10 @@ private extension SceneCoordinator {
|
||||||
let _viewController = ComposeViewController()
|
let _viewController = ComposeViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
viewController = _viewController
|
viewController = _viewController
|
||||||
|
case .hashtagTimeline(let viewModel):
|
||||||
|
let _viewController = HashtagTimelineViewController()
|
||||||
|
_viewController.viewModel = viewModel
|
||||||
|
viewController = _viewController
|
||||||
case .profile(let viewModel):
|
case .profile(let viewModel):
|
||||||
let _viewController = ProfileViewController()
|
let _viewController = ProfileViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
|
@ -222,6 +241,12 @@ private extension SceneCoordinator {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewController = alertController
|
viewController = alertController
|
||||||
|
case .safari(let url):
|
||||||
|
guard let scheme = url.scheme?.lowercased(),
|
||||||
|
scheme == "http" || scheme == "https" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
viewController = SFSafariViewController(url: url)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
case .publicTimeline:
|
case .publicTimeline:
|
||||||
let _viewController = PublicTimelineViewController()
|
let _viewController = PublicTimelineViewController()
|
||||||
|
|
|
@ -25,7 +25,7 @@ final class StatusFetchedResultsController: NSObject {
|
||||||
// output
|
// output
|
||||||
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||||
|
|
||||||
init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) {
|
init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) {
|
||||||
self.domain.value = domain ?? ""
|
self.domain.value = domain ?? ""
|
||||||
self.fetchedResultsController = {
|
self.fetchedResultsController = {
|
||||||
let fetchRequest = Status.sortedFetchRequest
|
let fetchRequest = Status.sortedFetchRequest
|
||||||
|
@ -52,10 +52,11 @@ final class StatusFetchedResultsController: NSObject {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] domain, ids in
|
.sink { [weak self] domain, ids in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
var predicates = [Status.predicate(domain: domain ?? "", ids: ids)]
|
||||||
Status.predicate(domain: domain ?? "", ids: ids),
|
if let additionalPredicate = additionalTweetPredicate {
|
||||||
additionalTweetPredicate
|
predicates.append(additionalPredicate)
|
||||||
])
|
}
|
||||||
|
self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||||
do {
|
do {
|
||||||
try self.fetchedResultsController.performFetch()
|
try self.fetchedResultsController.performFetch()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -22,6 +22,8 @@ enum Item {
|
||||||
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
|
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
|
||||||
case publicMiddleLoader(statusID: String)
|
case publicMiddleLoader(statusID: String)
|
||||||
case bottomLoader
|
case bottomLoader
|
||||||
|
|
||||||
|
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol StatusContentWarningAttribute {
|
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 {
|
extension Item: Equatable {
|
||||||
|
@ -65,12 +91,14 @@ extension Item: Equatable {
|
||||||
return objectIDLeft == objectIDRight
|
return objectIDLeft == objectIDRight
|
||||||
case (.status(let objectIDLeft, _), .status(let objectIDRight, _)):
|
case (.status(let objectIDLeft, _), .status(let objectIDRight, _)):
|
||||||
return objectIDLeft == 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)):
|
case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)):
|
||||||
return upperLeft == 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:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -84,14 +112,16 @@ extension Item: Hashable {
|
||||||
hasher.combine(objectID)
|
hasher.combine(objectID)
|
||||||
case .status(let objectID, _):
|
case .status(let objectID, _):
|
||||||
hasher.combine(objectID)
|
hasher.combine(objectID)
|
||||||
case .publicMiddleLoader(let upper):
|
|
||||||
hasher.combine(String(describing: Item.publicMiddleLoader.self))
|
|
||||||
hasher.combine(upper)
|
|
||||||
case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper):
|
case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper):
|
||||||
hasher.combine(String(describing: Item.homeMiddleLoader.self))
|
hasher.combine(String(describing: Item.homeMiddleLoader.self))
|
||||||
hasher.combine(upper)
|
hasher.combine(upper)
|
||||||
|
case .publicMiddleLoader(let upper):
|
||||||
|
hasher.combine(String(describing: Item.publicMiddleLoader.self))
|
||||||
|
hasher.combine(upper)
|
||||||
case .bottomLoader:
|
case .bottomLoader:
|
||||||
hasher.combine(String(describing: Item.bottomLoader.self))
|
hasher.combine(String(describing: Item.bottomLoader.self))
|
||||||
|
case .emptyStateHeader(let attribute):
|
||||||
|
hasher.combine(attribute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,16 +27,16 @@ extension CategoryPickerSection {
|
||||||
cell.categoryView.titleLabel.text = item.title
|
cell.categoryView.titleLabel.text = item.title
|
||||||
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
|
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
|
||||||
if cell.isSelected {
|
if cell.isSelected {
|
||||||
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color
|
cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color
|
||||||
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
|
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
|
||||||
if case .all = item {
|
if case .all = item {
|
||||||
cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color
|
cell.categoryView.titleLabel.textColor = .white
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color
|
cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.systemBackground.color
|
||||||
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
|
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
|
||||||
if case .all = item {
|
if case .all = item {
|
||||||
cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color
|
cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,8 @@ enum ComposeStatusSection: Equatable, Hashable {
|
||||||
extension ComposeStatusSection {
|
extension ComposeStatusSection {
|
||||||
enum ComposeKind {
|
enum ComposeKind {
|
||||||
case post
|
case post
|
||||||
|
case hashtag(hashtag: String)
|
||||||
|
case mention(mastodonUserObjectID: NSManagedObjectID)
|
||||||
case reply(repliedToStatusObjectID: NSManagedObjectID)
|
case reply(repliedToStatusObjectID: NSManagedObjectID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,18 @@ import UIKit
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
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 {
|
enum PollSection: Equatable, Hashable {
|
||||||
case main
|
case main
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,12 +79,17 @@ extension StatusSection {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||||
cell.startAnimating()
|
cell.startAnimating()
|
||||||
return cell
|
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 {
|
extension StatusSection {
|
||||||
|
|
||||||
static func configure(
|
static func configure(
|
||||||
cell: StatusTableViewCell,
|
cell: StatusTableViewCell,
|
||||||
dependency: NeedsDependency,
|
dependency: NeedsDependency,
|
||||||
|
@ -473,6 +478,14 @@ extension StatusSection {
|
||||||
snapshot.appendItems(pollItems, toSection: .main)
|
snapshot.appendItems(pollItems, toSection: .main)
|
||||||
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
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 {
|
extension StatusSection {
|
||||||
|
|
|
@ -64,11 +64,8 @@ extension ActiveLabel {
|
||||||
/// account field
|
/// account field
|
||||||
func configure(field: String) {
|
func configure(field: String) {
|
||||||
activeEntities.removeAll()
|
activeEntities.removeAll()
|
||||||
if let parseResult = try? MastodonField.parse(field: field) {
|
let parseResult = MastodonField.parse(field: field)
|
||||||
text = parseResult.value
|
text = parseResult.value
|
||||||
activeEntities = parseResult.activeEntities
|
activeEntities = parseResult.activeEntities
|
||||||
} else {
|
|
||||||
text = ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
// Array+removeDuplicates.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array
|
||||||
|
extension Array where Element: Hashable {
|
||||||
|
func removingDuplicates() -> [Element] {
|
||||||
|
var addedDict = [Element: Bool]()
|
||||||
|
|
||||||
|
return filter {
|
||||||
|
addedDict.updateValue(true, forKey: $0) == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeDuplicates() {
|
||||||
|
self = self.removingDuplicates()
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,8 @@ extension MastodonUser.Property {
|
||||||
statusesCount: entity.statusesCount,
|
statusesCount: entity.statusesCount,
|
||||||
followingCount: entity.followingCount,
|
followingCount: entity.followingCount,
|
||||||
followersCount: entity.followersCount,
|
followersCount: entity.followersCount,
|
||||||
|
locked: entity.locked,
|
||||||
|
bot: entity.bot,
|
||||||
createdAt: entity.createdAt,
|
createdAt: entity.createdAt,
|
||||||
networkDate: networkDate
|
networkDate: networkDate
|
||||||
)
|
)
|
||||||
|
@ -39,7 +41,12 @@ extension MastodonUser {
|
||||||
}
|
}
|
||||||
|
|
||||||
var acctWithDomain: String {
|
var acctWithDomain: String {
|
||||||
return username + "@" + domain
|
if !acct.contains("@") {
|
||||||
|
// Safe concat due to username cannot contains "@"
|
||||||
|
return username + "@" + domain
|
||||||
|
} else {
|
||||||
|
return acct
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import UIKit
|
||||||
// SeeAlso: `AdaptiveStatusBarStyleNavigationController`
|
// SeeAlso: `AdaptiveStatusBarStyleNavigationController`
|
||||||
extension UINavigationController {
|
extension UINavigationController {
|
||||||
open override var childForStatusBarStyle: UIViewController? {
|
open override var childForStatusBarStyle: UIViewController? {
|
||||||
assertionFailure("Won't enter here")
|
|
||||||
return visibleViewController
|
return visibleViewController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,6 @@ internal enum Asset {
|
||||||
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
|
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
|
||||||
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
|
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 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 systemBackground = ColorAsset(name: "Colors/Background/system.background")
|
||||||
internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background")
|
internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background")
|
||||||
internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background")
|
internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background")
|
||||||
|
@ -54,6 +53,7 @@ internal enum Asset {
|
||||||
internal enum Button {
|
internal enum Button {
|
||||||
internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar")
|
internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar")
|
||||||
internal static let disabled = ColorAsset(name: "Colors/Button/disabled")
|
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 static let normal = ColorAsset(name: "Colors/Button/normal")
|
||||||
}
|
}
|
||||||
internal enum Icon {
|
internal enum Icon {
|
||||||
|
@ -73,26 +73,21 @@ internal enum Asset {
|
||||||
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
|
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
|
||||||
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
|
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
|
||||||
}
|
}
|
||||||
internal static let backgroundLight = ColorAsset(name: "Colors/backgroundLight")
|
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
|
||||||
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 danger = ColorAsset(name: "Colors/danger")
|
internal static let danger = ColorAsset(name: "Colors/danger")
|
||||||
internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow")
|
internal static let disabled = ColorAsset(name: "Colors/disabled")
|
||||||
internal static let lightBackground = ColorAsset(name: "Colors/lightBackground")
|
internal static let inactive = ColorAsset(name: "Colors/inactive")
|
||||||
internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue")
|
internal static let successGreen = ColorAsset(name: "Colors/success.green")
|
||||||
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 systemOrange = ColorAsset(name: "Colors/system.orange")
|
internal static let systemOrange = ColorAsset(name: "Colors/system.orange")
|
||||||
}
|
}
|
||||||
internal enum Connectivity {
|
internal enum Connectivity {
|
||||||
internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split")
|
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 Welcome {
|
||||||
internal enum Illustration {
|
internal enum Illustration {
|
||||||
internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan")
|
internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan")
|
||||||
|
|
|
@ -92,6 +92,10 @@ internal enum L10n {
|
||||||
internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block")
|
internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block")
|
||||||
/// Blocked
|
/// Blocked
|
||||||
internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked")
|
internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked")
|
||||||
|
/// Block %@
|
||||||
|
internal static func blockUser(_ p1: Any) -> String {
|
||||||
|
return L10n.tr("Localizable", "Common.Controls.Firendship.BlockUser", String(describing: p1))
|
||||||
|
}
|
||||||
/// Edit info
|
/// Edit info
|
||||||
internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo")
|
internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo")
|
||||||
/// Follow
|
/// Follow
|
||||||
|
@ -102,6 +106,24 @@ internal enum L10n {
|
||||||
internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute")
|
internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute")
|
||||||
/// Muted
|
/// Muted
|
||||||
internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.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 {
|
internal enum Status {
|
||||||
/// Tap to reveal that may be sensitive
|
/// Tap to reveal that may be sensitive
|
||||||
|
@ -150,6 +172,16 @@ internal enum L10n {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
internal enum Timeline {
|
internal enum Timeline {
|
||||||
|
internal enum Header {
|
||||||
|
/// You can’t view Artbot’s profile\n until they unblock you.
|
||||||
|
internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning")
|
||||||
|
/// You can’t view Artbot’s 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 {
|
internal enum Loader {
|
||||||
/// Loading missing posts...
|
/// Loading missing posts...
|
||||||
internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts")
|
internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts")
|
||||||
|
@ -267,6 +299,12 @@ internal enum L10n {
|
||||||
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title")
|
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
internal enum Hashtag {
|
||||||
|
/// %@ people talking
|
||||||
|
internal static func prompt(_ p1: Any) -> String {
|
||||||
|
return L10n.tr("Localizable", "Scene.Hashtag.Prompt", String(describing: p1))
|
||||||
|
}
|
||||||
|
}
|
||||||
internal enum HomeTimeline {
|
internal enum HomeTimeline {
|
||||||
/// Home
|
/// Home
|
||||||
internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title")
|
internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title")
|
||||||
|
@ -290,6 +328,24 @@ internal enum L10n {
|
||||||
/// posts
|
/// posts
|
||||||
internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.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 {
|
internal enum SegmentedControl {
|
||||||
/// Media
|
/// Media
|
||||||
internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media")
|
internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media")
|
||||||
|
|
|
@ -11,7 +11,7 @@ import ActiveLabel
|
||||||
enum MastodonField {
|
enum MastodonField {
|
||||||
|
|
||||||
static func parse(field string: String) -> ParseResult {
|
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 hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
|
||||||
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
|
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -84,6 +84,8 @@ extension AvatarConfigurableView {
|
||||||
completion: nil
|
completion: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureLayerBorder(view: avatarImageView, configuration: configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let avatarButton = configurableAvatarButton {
|
if let avatarButton = configurableAvatarButton {
|
||||||
|
@ -110,9 +112,24 @@ extension AvatarConfigurableView {
|
||||||
completion: nil
|
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) { }
|
func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration) { }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -121,10 +138,19 @@ struct AvatarConfigurableViewConfiguration {
|
||||||
|
|
||||||
let avatarImageURL: URL?
|
let avatarImageURL: URL?
|
||||||
let placeholderImage: UIImage?
|
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.avatarImageURL = avatarImageURL
|
||||||
self.placeholderImage = placeholderImage
|
self.placeholderImage = placeholderImage
|
||||||
|
self.borderColor = borderColor
|
||||||
|
self.borderWidth = borderWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||||
StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell)
|
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
|
// MARK: - ActionToolbarContainerDelegate
|
||||||
|
|
|
@ -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 {
|
extension StatusProviderFacade {
|
||||||
|
|
||||||
static func responseToStatusLikeAction(provider: StatusProvider) {
|
static func responseToStatusLikeAction(provider: StatusProvider) {
|
||||||
|
|
|
@ -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>
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -5,9 +5,9 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "255",
|
"blue" : "0xFE",
|
||||||
"green" : "255",
|
"green" : "0xFF",
|
||||||
"red" : "255"
|
"red" : "0xFE"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
@ -23,9 +23,9 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0x37",
|
"blue" : "0x2E",
|
||||||
"green" : "0x2D",
|
"green" : "0x2C",
|
||||||
"red" : "0x29"
|
"red" : "0x2C"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|
|
@ -23,9 +23,9 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.216",
|
"blue" : "0x00",
|
||||||
"green" : "0.176",
|
"green" : "0x00",
|
||||||
"red" : "0.161"
|
"red" : "0x00"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0xFF",
|
"blue" : "0xFE",
|
||||||
"green" : "0xFF",
|
"green" : "0xFF",
|
||||||
"red" : "0xFF"
|
"red" : "0xFE"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
@ -23,9 +23,9 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0x2B",
|
"blue" : "0x2E",
|
||||||
"green" : "0x23",
|
"green" : "0x2C",
|
||||||
"red" : "0x1F"
|
"red" : "0x2C"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|
|
@ -23,9 +23,9 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0x2B",
|
"blue" : "0x00",
|
||||||
"green" : "0x23",
|
"green" : "0x00",
|
||||||
"red" : "0x1F"
|
"red" : "0x00"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|
|
@ -5,9 +5,27 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "140",
|
"blue" : "0.784",
|
||||||
"green" : "130",
|
"green" : "0.682",
|
||||||
"red" : "110"
|
"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"
|
"idiom" : "universal"
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,9 +5,9 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "217",
|
"blue" : "0xD9",
|
||||||
"green" : "144",
|
"green" : "0x90",
|
||||||
"red" : "43"
|
"red" : "0x2B"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "0.600",
|
"alpha" : "0.600",
|
||||||
"blue" : "67",
|
"blue" : "0x43",
|
||||||
"green" : "60",
|
"green" : "0x3C",
|
||||||
"red" : "60"
|
"red" : "0x3C"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"provides-namespace" : true
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,10 +4,10 @@
|
||||||
"color" : {
|
"color" : {
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "0.600",
|
||||||
"blue" : "0.910",
|
"blue" : "0.961",
|
||||||
"green" : "0.882",
|
"green" : "0.922",
|
||||||
"red" : "0.851"
|
"red" : "0.922"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"provides-namespace" : true
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,12 +29,19 @@ Please check your internet connection.";
|
||||||
"Common.Controls.Actions.TakePhoto" = "Take photo";
|
"Common.Controls.Actions.TakePhoto" = "Take photo";
|
||||||
"Common.Controls.Actions.TryAgain" = "Try Again";
|
"Common.Controls.Actions.TryAgain" = "Try Again";
|
||||||
"Common.Controls.Firendship.Block" = "Block";
|
"Common.Controls.Firendship.Block" = "Block";
|
||||||
|
"Common.Controls.Firendship.BlockUser" = "Block %@";
|
||||||
"Common.Controls.Firendship.Blocked" = "Blocked";
|
"Common.Controls.Firendship.Blocked" = "Blocked";
|
||||||
"Common.Controls.Firendship.EditInfo" = "Edit info";
|
"Common.Controls.Firendship.EditInfo" = "Edit info";
|
||||||
"Common.Controls.Firendship.Follow" = "Follow";
|
"Common.Controls.Firendship.Follow" = "Follow";
|
||||||
"Common.Controls.Firendship.Following" = "Following";
|
"Common.Controls.Firendship.Following" = "Following";
|
||||||
"Common.Controls.Firendship.Mute" = "Mute";
|
"Common.Controls.Firendship.Mute" = "Mute";
|
||||||
|
"Common.Controls.Firendship.MuteUser" = "Mute %@";
|
||||||
"Common.Controls.Firendship.Muted" = "Muted";
|
"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.MediaContentWarning" = "Tap to reveal that may be sensitive";
|
||||||
"Common.Controls.Status.Poll.Closed" = "Closed";
|
"Common.Controls.Status.Poll.Closed" = "Closed";
|
||||||
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
|
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
|
||||||
|
@ -47,6 +54,13 @@ Please check your internet connection.";
|
||||||
"Common.Controls.Status.StatusContentWarning" = "content warning";
|
"Common.Controls.Status.StatusContentWarning" = "content warning";
|
||||||
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
|
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
|
||||||
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
|
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
|
||||||
|
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile
|
||||||
|
until they unblock you.";
|
||||||
|
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view Artbot’s 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.LoadMissingPosts" = "Load missing posts";
|
||||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||||
"Common.Countable.Photo.Multiple" = "photos";
|
"Common.Countable.Photo.Multiple" = "photos";
|
||||||
|
@ -88,6 +102,7 @@ uploaded to Mastodon.";
|
||||||
"Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@,
|
"Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@,
|
||||||
tap the link to confirm your account.";
|
tap the link to confirm your account.";
|
||||||
"Scene.ConfirmEmail.Title" = "One last thing.";
|
"Scene.ConfirmEmail.Title" = "One last thing.";
|
||||||
|
"Scene.Hashtag.Prompt" = "%@ people talking";
|
||||||
"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts";
|
"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts";
|
||||||
"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline";
|
"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline";
|
||||||
"Scene.HomeTimeline.NavigationBarState.Published" = "Published!";
|
"Scene.HomeTimeline.NavigationBarState.Published" = "Published!";
|
||||||
|
@ -96,6 +111,10 @@ tap the link to confirm your account.";
|
||||||
"Scene.Profile.Dashboard.Followers" = "followers";
|
"Scene.Profile.Dashboard.Followers" = "followers";
|
||||||
"Scene.Profile.Dashboard.Following" = "following";
|
"Scene.Profile.Dashboard.Following" = "following";
|
||||||
"Scene.Profile.Dashboard.Posts" = "posts";
|
"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.Media" = "Media";
|
||||||
"Scene.Profile.SegmentedControl.Posts" = "Posts";
|
"Scene.Profile.SegmentedControl.Posts" = "Posts";
|
||||||
"Scene.Profile.SegmentedControl.Replies" = "Replies";
|
"Scene.Profile.SegmentedControl.Replies" = "Replies";
|
||||||
|
|
|
@ -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)
|
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 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
|
// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
|
||||||
// precondition :\B with following space
|
// precondition :\B with following space
|
||||||
let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))")
|
let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))")
|
||||||
|
|
|
@ -62,7 +62,7 @@ extension ComposeViewModel {
|
||||||
case .reply(let statusObjectID):
|
case .reply(let statusObjectID):
|
||||||
snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo)
|
snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo)
|
||||||
snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], 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)
|
snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status)
|
||||||
}
|
}
|
||||||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
|
@ -56,6 +56,10 @@ final class ComposeViewModel {
|
||||||
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
|
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
|
||||||
let characterCount = CurrentValueSubject<Int, Never>(0)
|
let characterCount = CurrentValueSubject<Int, Never>(0)
|
||||||
|
|
||||||
|
// for hashtag: #<hashag>' '
|
||||||
|
// for mention: @<mention>' '
|
||||||
|
private(set) var preInsertedContent: String?
|
||||||
|
|
||||||
// custom emojis
|
// custom emojis
|
||||||
var customEmojiViewModelSubscription: AnyCancellable?
|
var customEmojiViewModelSubscription: AnyCancellable?
|
||||||
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
|
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
|
||||||
|
@ -76,12 +80,30 @@ final class ComposeViewModel {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.composeKind = composeKind
|
self.composeKind = composeKind
|
||||||
switch composeKind {
|
switch composeKind {
|
||||||
case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
|
case .post, .hashtag, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
|
||||||
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
|
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
|
||||||
}
|
}
|
||||||
self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value)
|
self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value)
|
||||||
self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
|
self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
|
||||||
// end init
|
// 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
|
isCustomEmojiComposing
|
||||||
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
|
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
|
||||||
|
@ -195,9 +217,16 @@ final class ComposeViewModel {
|
||||||
// bind modal dismiss state
|
// bind modal dismiss state
|
||||||
composeStatusAttribute.composeContent
|
composeStatusAttribute.composeContent
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.map { content in
|
.map { [weak self] content in
|
||||||
let content = content ?? ""
|
let content = content ?? ""
|
||||||
return content.isEmpty
|
if content.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// if preInsertedContent plus a space is equal to the content, simply dismiss the modal
|
||||||
|
if let preInsertedContent = self?.preInsertedContent {
|
||||||
|
return content == (preInsertedContent + " ")
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
.assign(to: \.value, on: shouldDismiss)
|
.assign(to: \.value, on: shouldDismiss)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
@ -304,6 +333,11 @@ final class ComposeViewModel {
|
||||||
self.isPollToolbarButtonEnabled.value = !shouldPollDisable
|
self.isPollToolbarButtonEnabled.value = !shouldPollDisable
|
||||||
})
|
})
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
if let preInsertedContent = preInsertedContent {
|
||||||
|
// add a space after the injected text
|
||||||
|
composeStatusAttribute.composeContent.send(preInsertedContent + " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewController+StatusProvider.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
// MARK: - StatusProvider
|
||||||
|
extension HashtagTimelineViewController: StatusProvider {
|
||||||
|
|
||||||
|
func status() -> Future<Status?, Never> {
|
||||||
|
return Future { promise in promise(.success(nil)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never> {
|
||||||
|
return Future { promise in
|
||||||
|
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
promise(.success(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
|
||||||
|
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
promise(.success(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item {
|
||||||
|
case .status(let objectID, _):
|
||||||
|
let managedObjectContext = self.viewModel.context.managedObjectContext
|
||||||
|
managedObjectContext.perform {
|
||||||
|
let status = managedObjectContext.object(with: objectID) as? Status
|
||||||
|
promise(.success(status))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
promise(.success(nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
|
||||||
|
return Future { promise in promise(.success(nil)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedObjectContext: NSManagedObjectContext {
|
||||||
|
return viewModel.context.managedObjectContext
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
|
||||||
|
return viewModel.diffableDataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
|
||||||
|
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
|
||||||
|
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func items(indexPaths: [IndexPath]) -> [Item] {
|
||||||
|
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var items: [Item] = []
|
||||||
|
for indexPath in indexPaths {
|
||||||
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
|
||||||
|
items.append(item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,317 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewController.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import AVKit
|
||||||
|
import Combine
|
||||||
|
import GameplayKit
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
class HashtagTimelineViewController: UIViewController, NeedsDependency {
|
||||||
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
var viewModel: HashtagTimelineViewModel!
|
||||||
|
|
||||||
|
let composeBarButtonItem: UIBarButtonItem = {
|
||||||
|
let barButtonItem = UIBarButtonItem()
|
||||||
|
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
|
||||||
|
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
return barButtonItem
|
||||||
|
}()
|
||||||
|
|
||||||
|
let tableView: UITableView = {
|
||||||
|
let tableView = ControlContainableTableView()
|
||||||
|
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||||
|
tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self))
|
||||||
|
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
|
||||||
|
return tableView
|
||||||
|
}()
|
||||||
|
|
||||||
|
let refreshControl = UIRefreshControl()
|
||||||
|
|
||||||
|
let titleView = HashtagTimelineNavigationBarTitleView()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewController {
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
title = "#\(viewModel.hashtag)"
|
||||||
|
titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil)
|
||||||
|
navigationItem.titleView = titleView
|
||||||
|
|
||||||
|
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
|
||||||
|
navigationItem.rightBarButtonItem = composeBarButtonItem
|
||||||
|
|
||||||
|
composeBarButtonItem.target = self
|
||||||
|
composeBarButtonItem.action = #selector(HashtagTimelineViewController.composeBarButtonItemPressed(_:))
|
||||||
|
|
||||||
|
tableView.refreshControl = refreshControl
|
||||||
|
refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
|
||||||
|
|
||||||
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(tableView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
viewModel.tableView = tableView
|
||||||
|
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.prefetchDataSource = self
|
||||||
|
viewModel.setupDiffableDataSource(
|
||||||
|
for: tableView,
|
||||||
|
dependency: self,
|
||||||
|
statusTableViewCellDelegate: self,
|
||||||
|
timelineMiddleLoaderTableViewCellDelegate: self
|
||||||
|
)
|
||||||
|
|
||||||
|
// bind refresh control
|
||||||
|
viewModel.isFetchingLatestTimeline
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isFetching in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if !isFetching {
|
||||||
|
UIView.animate(withDuration: 0.5) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.refreshControl.endRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
viewModel.hashtagEntity
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] tag in
|
||||||
|
self?.updatePromptTitle()
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
viewModel.fetchTag()
|
||||||
|
guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return }
|
||||||
|
|
||||||
|
refreshControl.beginRefreshing()
|
||||||
|
refreshControl.sendActions(for: .valueChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
context.videoPlaybackService.viewDidDisappear(from: self)
|
||||||
|
context.audioPlaybackService.viewDidDisappear(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
super.viewWillTransition(to: size, with: coordinator)
|
||||||
|
|
||||||
|
coordinator.animate { _ in
|
||||||
|
// do nothing
|
||||||
|
} completion: { _ in
|
||||||
|
// fix AutoLayout cell height not update after rotate issue
|
||||||
|
self.viewModel.cellFrameCache.removeAllObjects()
|
||||||
|
self.tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePromptTitle() {
|
||||||
|
var subtitle: String?
|
||||||
|
defer {
|
||||||
|
titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle)
|
||||||
|
}
|
||||||
|
guard let histories = viewModel.hashtagEntity.value?.history else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if histories.isEmpty {
|
||||||
|
// No tag history, remove the prompt title
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
let sortedHistory = histories.sorted { (h1, h2) -> Bool in
|
||||||
|
return h1.day > h2.day
|
||||||
|
}
|
||||||
|
let peopleTalkingNumber = sortedHistory
|
||||||
|
.prefix(2)
|
||||||
|
.compactMap({ Int($0.accounts) })
|
||||||
|
.reduce(0, +)
|
||||||
|
subtitle = "\(peopleTalkingNumber)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: .hashtag(hashtag: viewModel.hashtag))
|
||||||
|
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||||
|
guard viewModel.loadLatestStateMachine.enter(HashtagTimelineViewModel.LoadLatestState.Loading.self) else {
|
||||||
|
sender.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIScrollViewDelegate
|
||||||
|
extension HashtagTimelineViewController {
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
handleScrollViewDidScroll(scrollView)
|
||||||
|
// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer {
|
||||||
|
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||||
|
typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading
|
||||||
|
var loadMoreConfigurableTableView: UITableView { return tableView }
|
||||||
|
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDelegate
|
||||||
|
extension HashtagTimelineViewController: UITableViewDelegate {
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
// guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
|
||||||
|
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
|
||||||
|
//
|
||||||
|
// guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
|
||||||
|
// return 200
|
||||||
|
// }
|
||||||
|
// // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
|
||||||
|
//
|
||||||
|
// return ceil(frame.height)
|
||||||
|
// }
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||||
|
extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
|
||||||
|
func navigationBar() -> UINavigationBar? {
|
||||||
|
return navigationController?.navigationBar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSourcePrefetching
|
||||||
|
extension HashtagTimelineViewController: UITableViewDataSourcePrefetching {
|
||||||
|
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
|
handleTableView(tableView, prefetchRowsAt: indexPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TimelineMiddleLoaderTableViewCellDelegate
|
||||||
|
extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
|
||||||
|
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) {
|
||||||
|
guard let upperTimelineIndexObjectID = timelineIndexobjectID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModel.loadMiddleSateMachineList
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] ids in
|
||||||
|
guard let _ = self else { return }
|
||||||
|
if let stateMachine = ids[upperTimelineIndexObjectID] {
|
||||||
|
guard let state = stateMachine.currentState else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// make success state same as loading due to snapshot updating delay
|
||||||
|
let isLoading = state is HashtagTimelineViewModel.LoadMiddleState.Loading || state is HashtagTimelineViewModel.LoadMiddleState.Success
|
||||||
|
if isLoading {
|
||||||
|
cell.startAnimating()
|
||||||
|
} else {
|
||||||
|
cell.stopAnimating()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cell.stopAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
|
|
||||||
|
var dict = viewModel.loadMiddleSateMachineList.value
|
||||||
|
if let _ = dict[upperTimelineIndexObjectID] {
|
||||||
|
// do nothing
|
||||||
|
} else {
|
||||||
|
let stateMachine = GKStateMachine(states: [
|
||||||
|
HashtagTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
|
||||||
|
HashtagTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
|
||||||
|
HashtagTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
|
||||||
|
HashtagTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
|
||||||
|
])
|
||||||
|
stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Initial.self)
|
||||||
|
dict[upperTimelineIndexObjectID] = stateMachine
|
||||||
|
viewModel.loadMiddleSateMachineList.value = dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) {
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
|
guard let indexPath = tableView.indexPath(for: cell) else { return }
|
||||||
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
|
||||||
|
switch item {
|
||||||
|
case .homeMiddleLoader(let upper):
|
||||||
|
guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Loading.self)
|
||||||
|
default:
|
||||||
|
assertionFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPlayerViewControllerDelegate
|
||||||
|
extension HashtagTimelineViewController: AVPlayerViewControllerDelegate {
|
||||||
|
|
||||||
|
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - StatusTableViewCellDelegate
|
||||||
|
extension HashtagTimelineViewController: StatusTableViewCellDelegate {
|
||||||
|
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
||||||
|
func parent() -> UIViewController { return self }
|
||||||
|
}
|
|
@ -0,0 +1,124 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel+Diffable.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
func setupDiffableDataSource(
|
||||||
|
for tableView: UITableView,
|
||||||
|
dependency: NeedsDependency,
|
||||||
|
statusTableViewCellDelegate: StatusTableViewCellDelegate,
|
||||||
|
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
|
||||||
|
) {
|
||||||
|
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||||
|
.autoconnect()
|
||||||
|
.share()
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
|
diffableDataSource = StatusSection.tableViewDiffableDataSource(
|
||||||
|
for: tableView,
|
||||||
|
dependency: dependency,
|
||||||
|
managedObjectContext: context.managedObjectContext,
|
||||||
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
|
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||||
|
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compare old & new snapshots and generate new items
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
func generateStatusItems(newObjectIDs: [NSManagedObjectID]) {
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
guard let tableView = self.tableView else { return }
|
||||||
|
guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
|
||||||
|
|
||||||
|
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||||
|
|
||||||
|
let parentManagedObjectContext = fetchedResultsController.fetchedResultsController.managedObjectContext
|
||||||
|
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||||
|
managedObjectContext.parent = parentManagedObjectContext
|
||||||
|
|
||||||
|
let oldSnapshot = diffableDataSource.snapshot()
|
||||||
|
// let snapshot = snapshot as NSDiffableDataSourceSnapshot<Int, NSManagedObjectID>
|
||||||
|
|
||||||
|
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
|
||||||
|
for item in oldSnapshot.itemIdentifiers {
|
||||||
|
guard case let .status(objectID, attribute) = item else { continue }
|
||||||
|
oldSnapshotAttributeDict[objectID] = attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusItemList: [Item] = newObjectIDs.map {
|
||||||
|
let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute()
|
||||||
|
return Item.status(objectID: $0, attribute: attribute)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||||
|
newSnapshot.appendSections([.main])
|
||||||
|
|
||||||
|
// Check if there is a `needLoadMiddleIndex`
|
||||||
|
if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) {
|
||||||
|
// If yes, insert a `middleLoader` at the index
|
||||||
|
var newItems = statusItemList
|
||||||
|
newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: newObjectIDs[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1))
|
||||||
|
newSnapshot.appendItems(newItems, toSection: .main)
|
||||||
|
} else {
|
||||||
|
newSnapshot.appendItems(statusItemList, toSection: .main)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) {
|
||||||
|
newSnapshot.appendItems([.bottomLoader], toSection: .main)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
|
||||||
|
diffableDataSource.apply(newSnapshot)
|
||||||
|
self.isFetchingLatestTimeline.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
|
||||||
|
tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
|
||||||
|
tableView.contentOffset.y = tableView.contentOffset.y - difference.offset
|
||||||
|
self.isFetchingLatestTimeline.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Difference<T> {
|
||||||
|
let targetIndexPath: IndexPath
|
||||||
|
let offset: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateReloadSnapshotDifference<T: Hashable>(
|
||||||
|
navigationBar: UINavigationBar,
|
||||||
|
tableView: UITableView,
|
||||||
|
oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>,
|
||||||
|
newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>
|
||||||
|
) -> Difference<T>? {
|
||||||
|
guard oldSnapshot.numberOfItems != 0 else { return nil }
|
||||||
|
guard let item = oldSnapshot.itemIdentifiers.first as? Item, case Item.status = item else { return nil }
|
||||||
|
|
||||||
|
let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first!
|
||||||
|
|
||||||
|
guard let oldItemBeginIndexInNewSnapshot = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: oldItemAtBeginning) else { return nil }
|
||||||
|
|
||||||
|
if oldItemBeginIndexInNewSnapshot > 0 {
|
||||||
|
let targetIndexPath = IndexPath(row: oldItemBeginIndexInNewSnapshot, section: 0)
|
||||||
|
let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: IndexPath(row: 0, section: 0), navigationBar: navigationBar)
|
||||||
|
return Difference(
|
||||||
|
targetIndexPath: targetIndexPath,
|
||||||
|
offset: offset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel+LoadLatestState.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import GameplayKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
class LoadLatestState: GKState {
|
||||||
|
weak var viewModel: HashtagTimelineViewModel?
|
||||||
|
|
||||||
|
init(viewModel: HashtagTimelineViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||||
|
viewModel?.loadLatestStateMachinePublisher.send(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
class Initial: HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loading: HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Fail.self || stateClass == Idle.self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
// sign out when loading will enter here
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: only set large count when using Wi-Fi
|
||||||
|
viewModel.context.apiService.hashtagTimeline(
|
||||||
|
domain: activeMastodonAuthenticationBox.domain,
|
||||||
|
hashtag: viewModel.hashtag,
|
||||||
|
authorizationBox: activeMastodonAuthenticationBox)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
// TODO: handle error
|
||||||
|
viewModel.isFetchingLatestTimeline.value = false
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statues failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
stateMachine.enter(Idle.self)
|
||||||
|
|
||||||
|
} receiveValue: { response in
|
||||||
|
let newStatusIDList = response.value.map { $0.id }
|
||||||
|
|
||||||
|
// When response data:
|
||||||
|
// 1. is not empty
|
||||||
|
// 2. last status are not recorded
|
||||||
|
// Then we may have middle data to load
|
||||||
|
var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value
|
||||||
|
if !oldStatusIDs.isEmpty, let lastNewStatusID = newStatusIDList.last,
|
||||||
|
!oldStatusIDs.contains(lastNewStatusID) {
|
||||||
|
viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1)
|
||||||
|
} else {
|
||||||
|
viewModel.needLoadMiddleIndex = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldStatusIDs.insert(contentsOf: newStatusIDList, at: 0)
|
||||||
|
let newIDs = oldStatusIDs.removingDuplicates()
|
||||||
|
|
||||||
|
viewModel.fetchedResultsController.statusIDs.value = newIDs
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fail: HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self || stateClass == Idle.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Idle: HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel+LoadMiddleState.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import GameplayKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
class LoadMiddleState: GKState {
|
||||||
|
weak var viewModel: HashtagTimelineViewModel?
|
||||||
|
let upperStatusObjectID: NSManagedObjectID
|
||||||
|
|
||||||
|
init(viewModel: HashtagTimelineViewModel, upperStatusObjectID: NSManagedObjectID) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.upperStatusObjectID = upperStatusObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
var dict = viewModel.loadMiddleSateMachineList.value
|
||||||
|
dict[upperStatusObjectID] = stateMachine
|
||||||
|
viewModel.loadMiddleSateMachineList.value = dict // trigger value change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
|
||||||
|
class Initial: HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loading: HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
// guard let viewModel = viewModel else { return false }
|
||||||
|
return stateClass == Success.self || stateClass == Fail.self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else {
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
|
||||||
|
status.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: only set large count when using Wi-Fi
|
||||||
|
let maxID = upperStatusObject.id
|
||||||
|
viewModel.context.apiService.hashtagTimeline(
|
||||||
|
domain: activeMastodonAuthenticationBox.domain,
|
||||||
|
maxID: maxID,
|
||||||
|
hashtag: viewModel.hashtag,
|
||||||
|
authorizationBox: activeMastodonAuthenticationBox)
|
||||||
|
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { completion in
|
||||||
|
// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
// TODO: handle error
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
case .finished:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { response in
|
||||||
|
stateMachine.enter(Success.self)
|
||||||
|
|
||||||
|
let newStatusIDList = response.value.map { $0.id }
|
||||||
|
|
||||||
|
var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value
|
||||||
|
if let indexToInsert = oldStatusIDs.firstIndex(of: maxID) {
|
||||||
|
// When response data:
|
||||||
|
// 1. is not empty
|
||||||
|
// 2. last status are not recorded
|
||||||
|
// Then we may have middle data to load
|
||||||
|
if let lastNewStatusID = newStatusIDList.last,
|
||||||
|
!oldStatusIDs.contains(lastNewStatusID) {
|
||||||
|
viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count
|
||||||
|
} else {
|
||||||
|
viewModel.needLoadMiddleIndex = nil
|
||||||
|
}
|
||||||
|
oldStatusIDs.insert(contentsOf: newStatusIDList, at: indexToInsert + 1)
|
||||||
|
oldStatusIDs.removeDuplicates()
|
||||||
|
} else {
|
||||||
|
// Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index
|
||||||
|
// Then there is no need to set a `loadMiddleState` cell
|
||||||
|
viewModel.needLoadMiddleIndex = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.fetchedResultsController.statusIDs.value = oldStatusIDs
|
||||||
|
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fail: HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
// guard let viewModel = viewModel else { return false }
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Success: HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
// guard let viewModel = viewModel else { return false }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel+LoadOldestState.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import GameplayKit
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
class LoadOldestState: GKState {
|
||||||
|
weak var viewModel: HashtagTimelineViewModel?
|
||||||
|
|
||||||
|
init(viewModel: HashtagTimelineViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||||
|
viewModel?.loadOldestStateMachinePublisher.send(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
class Initial: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
guard let viewModel = viewModel else { return false }
|
||||||
|
guard !(viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false }
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loading: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
assertionFailure()
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let last = viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects?.last else {
|
||||||
|
stateMachine.enter(Idle.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: only set large count when using Wi-Fi
|
||||||
|
let maxID = last.id
|
||||||
|
viewModel.context.apiService.hashtagTimeline(
|
||||||
|
domain: activeMastodonAuthenticationBox.domain,
|
||||||
|
maxID: maxID,
|
||||||
|
hashtag: viewModel.hashtag,
|
||||||
|
authorizationBox: activeMastodonAuthenticationBox)
|
||||||
|
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { completion in
|
||||||
|
// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { response in
|
||||||
|
let statuses = response.value
|
||||||
|
// enter no more state when no new statuses
|
||||||
|
if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) {
|
||||||
|
stateMachine.enter(NoMore.self)
|
||||||
|
} else {
|
||||||
|
stateMachine.enter(Idle.self)
|
||||||
|
}
|
||||||
|
var newStatusIDs = viewModel.fetchedResultsController.statusIDs.value
|
||||||
|
let fetchedStatusIDList = statuses.map { $0.id }
|
||||||
|
newStatusIDs.append(contentsOf: fetchedStatusIDList)
|
||||||
|
viewModel.fetchedResultsController.statusIDs.value = newStatusIDs
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fail: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self || stateClass == Idle.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Idle: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoMore: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
// reset state if needs
|
||||||
|
return stateClass == Idle.self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
guard let viewModel = viewModel else { return }
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var snapshot = diffableDataSource.snapshot()
|
||||||
|
snapshot.deleteItems([.bottomLoader])
|
||||||
|
diffableDataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import GameplayKit
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
final class HashtagTimelineViewModel: NSObject {
|
||||||
|
|
||||||
|
let hashtag: String
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
var needLoadMiddleIndex: Int? = nil
|
||||||
|
|
||||||
|
// input
|
||||||
|
let context: AppContext
|
||||||
|
let fetchedResultsController: StatusFetchedResultsController
|
||||||
|
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
|
||||||
|
let hashtagEntity = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
|
||||||
|
|
||||||
|
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
|
||||||
|
weak var tableView: UITableView?
|
||||||
|
|
||||||
|
// output
|
||||||
|
// top loader
|
||||||
|
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
|
||||||
|
// exclude timeline middle fetcher state
|
||||||
|
let stateMachine = GKStateMachine(states: [
|
||||||
|
LoadLatestState.Initial(viewModel: self),
|
||||||
|
LoadLatestState.Loading(viewModel: self),
|
||||||
|
LoadLatestState.Fail(viewModel: self),
|
||||||
|
LoadLatestState.Idle(viewModel: self),
|
||||||
|
])
|
||||||
|
stateMachine.enter(LoadLatestState.Initial.self)
|
||||||
|
return stateMachine
|
||||||
|
}()
|
||||||
|
lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil)
|
||||||
|
// bottom loader
|
||||||
|
private(set) lazy var loadoldestStateMachine: GKStateMachine = {
|
||||||
|
// exclude timeline middle fetcher state
|
||||||
|
let stateMachine = GKStateMachine(states: [
|
||||||
|
LoadOldestState.Initial(viewModel: self),
|
||||||
|
LoadOldestState.Loading(viewModel: self),
|
||||||
|
LoadOldestState.Fail(viewModel: self),
|
||||||
|
LoadOldestState.Idle(viewModel: self),
|
||||||
|
LoadOldestState.NoMore(viewModel: self),
|
||||||
|
])
|
||||||
|
stateMachine.enter(LoadOldestState.Initial.self)
|
||||||
|
return stateMachine
|
||||||
|
}()
|
||||||
|
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
|
||||||
|
// middle loader
|
||||||
|
let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine
|
||||||
|
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||||
|
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||||
|
|
||||||
|
|
||||||
|
init(context: AppContext, hashtag: String) {
|
||||||
|
self.context = context
|
||||||
|
self.hashtag = hashtag
|
||||||
|
let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value
|
||||||
|
self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil)
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
fetchedResultsController.objectIDs
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] objectIds in
|
||||||
|
self?.generateStatusItems(newObjectIDs: objectIds)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTag() {
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
self?.hashtagEntity.send(matchedTag)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineTitleView.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/4/1.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class HashtagTimelineNavigationBarTitleView: UIView {
|
||||||
|
|
||||||
|
let containerView = UIStackView()
|
||||||
|
|
||||||
|
let titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
|
label.textAlignment = .center
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
let subtitleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .systemFont(ofSize: 12)
|
||||||
|
label.textColor = Asset.Colors.Label.secondary.color
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.isHidden = true
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineNavigationBarTitleView {
|
||||||
|
private func _init() {
|
||||||
|
containerView.axis = .vertical
|
||||||
|
containerView.alignment = .center
|
||||||
|
containerView.distribution = .fill
|
||||||
|
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(containerView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
containerView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
containerView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
containerView.addArrangedSubview(titleLabel)
|
||||||
|
containerView.addArrangedSubview(subtitleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTitle(hashtag: String, peopleNumber: String?) {
|
||||||
|
titleLabel.text = "#\(hashtag)"
|
||||||
|
if let peopleNumebr = peopleNumber {
|
||||||
|
subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr)
|
||||||
|
subtitleLabel.isHidden = false
|
||||||
|
} else {
|
||||||
|
subtitleLabel.text = nil
|
||||||
|
subtitleLabel.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -120,7 +120,7 @@ extension HomeTimelineNavigationBarTitleView {
|
||||||
configureButton(
|
configureButton(
|
||||||
title: L10n.Scene.HomeTimeline.NavigationBarState.published,
|
title: L10n.Scene.HomeTimeline.NavigationBarState.published,
|
||||||
textColor: .white,
|
textColor: .white,
|
||||||
backgroundColor: Asset.Colors.Background.success.color
|
backgroundColor: Asset.Colors.successGreen.color
|
||||||
)
|
)
|
||||||
button.isHidden = false
|
button.isHidden = false
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc
|
||||||
let openEmailButton: UIButton = {
|
let openEmailButton: UIButton = {
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
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.setTitleColor(.white, for: .normal)
|
||||||
button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal)
|
button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal)
|
||||||
button.layer.masksToBounds = true
|
button.layer.masksToBounds = true
|
||||||
|
@ -53,7 +53,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc
|
||||||
let dontReceiveButton: UIButton = {
|
let dontReceiveButton: UIButton = {
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.boldSystemFont(ofSize: 15))
|
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.setTitle(L10n.Scene.ConfirmEmail.Button.dontReceiveEmail, for: .normal)
|
||||||
button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside)
|
button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside)
|
||||||
return button
|
return button
|
||||||
|
|
|
@ -27,7 +27,7 @@ class PickServerCell: UITableViewCell {
|
||||||
let containerView: UIView = {
|
let containerView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
|
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
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
@ -35,7 +35,7 @@ class PickServerCell: UITableViewCell {
|
||||||
let domainLabel: UILabel = {
|
let domainLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .preferredFont(forTextStyle: .headline)
|
label.font = .preferredFont(forTextStyle: .headline)
|
||||||
label.textColor = Asset.Colors.lightDarkGray.color
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
label.adjustsFontForContentSizeCategory = true
|
label.adjustsFontForContentSizeCategory = true
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
return label
|
return label
|
||||||
|
@ -44,7 +44,7 @@ class PickServerCell: UITableViewCell {
|
||||||
let checkbox: UIImageView = {
|
let checkbox: UIImageView = {
|
||||||
let imageView = UIImageView()
|
let imageView = UIImageView()
|
||||||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
|
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
|
||||||
imageView.tintColor = Asset.Colors.lightSecondaryText.color
|
imageView.tintColor = Asset.Colors.Label.secondary.color
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
return imageView
|
return imageView
|
||||||
|
@ -54,7 +54,7 @@ class PickServerCell: UITableViewCell {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
label.numberOfLines = 0
|
label.numberOfLines = 0
|
||||||
label.textColor = Asset.Colors.lightDarkGray.color
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
label.adjustsFontForContentSizeCategory = true
|
label.adjustsFontForContentSizeCategory = true
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
return label
|
return label
|
||||||
|
@ -90,7 +90,7 @@ class PickServerCell: UITableViewCell {
|
||||||
let button = UIButton(type: .custom)
|
let button = UIButton(type: .custom)
|
||||||
button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal)
|
button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal)
|
||||||
button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected)
|
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.titleLabel?.font = .preferredFont(forTextStyle: .footnote)
|
||||||
button.translatesAutoresizingMaskIntoConstraints = false
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
return button
|
return button
|
||||||
|
@ -98,14 +98,14 @@ class PickServerCell: UITableViewCell {
|
||||||
|
|
||||||
let seperator: UIView = {
|
let seperator: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.backgroundColor = Asset.Colors.lightBackground.color
|
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let langValueLabel: UILabel = {
|
let langValueLabel: UILabel = {
|
||||||
let label = 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.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.adjustsFontForContentSizeCategory = true
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
@ -115,7 +115,7 @@ class PickServerCell: UITableViewCell {
|
||||||
|
|
||||||
let usersValueLabel: UILabel = {
|
let usersValueLabel: UILabel = {
|
||||||
let label = 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.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.adjustsFontForContentSizeCategory = true
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
@ -125,7 +125,7 @@ class PickServerCell: UITableViewCell {
|
||||||
|
|
||||||
let categoryValueLabel: UILabel = {
|
let categoryValueLabel: UILabel = {
|
||||||
let label = 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.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.adjustsFontForContentSizeCategory = true
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
@ -135,7 +135,7 @@ class PickServerCell: UITableViewCell {
|
||||||
|
|
||||||
let langTitleLabel: UILabel = {
|
let langTitleLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.textColor = Asset.Colors.lightDarkGray.color
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
label.font = .preferredFont(forTextStyle: .caption2)
|
label.font = .preferredFont(forTextStyle: .caption2)
|
||||||
label.text = L10n.Scene.ServerPicker.Label.language
|
label.text = L10n.Scene.ServerPicker.Label.language
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
|
@ -146,7 +146,7 @@ class PickServerCell: UITableViewCell {
|
||||||
|
|
||||||
let usersTitleLabel: UILabel = {
|
let usersTitleLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.textColor = Asset.Colors.lightDarkGray.color
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
label.font = .preferredFont(forTextStyle: .caption2)
|
label.font = .preferredFont(forTextStyle: .caption2)
|
||||||
label.text = L10n.Scene.ServerPicker.Label.users
|
label.text = L10n.Scene.ServerPicker.Label.users
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
|
@ -157,7 +157,7 @@ class PickServerCell: UITableViewCell {
|
||||||
|
|
||||||
let categoryTitleLabel: UILabel = {
|
let categoryTitleLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.textColor = Asset.Colors.lightDarkGray.color
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
label.font = .preferredFont(forTextStyle: .caption2)
|
label.font = .preferredFont(forTextStyle: .caption2)
|
||||||
label.text = L10n.Scene.ServerPicker.Label.category
|
label.text = L10n.Scene.ServerPicker.Label.category
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
|
|
|
@ -17,7 +17,7 @@ class PickServerSearchCell: UITableViewCell {
|
||||||
|
|
||||||
private var bgView: UIView = {
|
private var bgView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.backgroundColor = Asset.Colors.lightWhite.color
|
view.backgroundColor = Asset.Colors.Background.systemBackground.color
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.layer.maskedCorners = [
|
view.layer.maskedCorners = [
|
||||||
.layerMinXMinYCorner,
|
.layerMinXMinYCorner,
|
||||||
|
@ -30,7 +30,7 @@ class PickServerSearchCell: UITableViewCell {
|
||||||
|
|
||||||
private var textFieldBgView: UIView = {
|
private var textFieldBgView: UIView = {
|
||||||
let view = 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.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.layer.masksToBounds = true
|
view.layer.masksToBounds = true
|
||||||
view.layer.cornerRadius = 6
|
view.layer.cornerRadius = 6
|
||||||
|
@ -42,13 +42,13 @@ class PickServerSearchCell: UITableViewCell {
|
||||||
let textField = UITextField()
|
let textField = UITextField()
|
||||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||||
textField.font = .preferredFont(forTextStyle: .headline)
|
textField.font = .preferredFont(forTextStyle: .headline)
|
||||||
textField.tintColor = Asset.Colors.lightDarkGray.color
|
textField.tintColor = Asset.Colors.Label.primary.color
|
||||||
textField.textColor = Asset.Colors.lightDarkGray.color
|
textField.textColor = Asset.Colors.Label.primary.color
|
||||||
textField.adjustsFontForContentSizeCategory = true
|
textField.adjustsFontForContentSizeCategory = true
|
||||||
textField.attributedPlaceholder =
|
textField.attributedPlaceholder =
|
||||||
NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder,
|
NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder,
|
||||||
attributes: [.font: UIFont.preferredFont(forTextStyle: .headline),
|
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.clearButtonMode = .whileEditing
|
||||||
textField.autocapitalizationType = .none
|
textField.autocapitalizationType = .none
|
||||||
textField.autocorrectionType = .no
|
textField.autocorrectionType = .no
|
||||||
|
|
|
@ -48,7 +48,7 @@ extension PickServerCategoryView {
|
||||||
addSubview(bgView)
|
addSubview(bgView)
|
||||||
addSubview(titleLabel)
|
addSubview(titleLabel)
|
||||||
|
|
||||||
bgView.backgroundColor = Asset.Colors.lightWhite.color
|
bgView.backgroundColor = Asset.Colors.Background.systemBackground.color
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||||
|
|
|
@ -222,6 +222,6 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { }
|
||||||
extension WelcomeViewController: UIAdaptivePresentationControllerDelegate {
|
extension WelcomeViewController: UIAdaptivePresentationControllerDelegate {
|
||||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
||||||
// make underneath view controller alive to fix layout issue due to view life cycle
|
// make underneath view controller alive to fix layout issue due to view life cycle
|
||||||
return .overFullScreen
|
return .fullScreen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ final class ProfileHeaderViewController: UIViewController {
|
||||||
|
|
||||||
weak var delegate: ProfileHeaderViewControllerDelegate?
|
weak var delegate: ProfileHeaderViewControllerDelegate?
|
||||||
|
|
||||||
let profileBannerView = ProfileHeaderView()
|
let profileHeaderView = ProfileHeaderView()
|
||||||
let pageSegmentedControl: UISegmentedControl = {
|
let pageSegmentedControl: UISegmentedControl = {
|
||||||
let segmenetedControl = UISegmentedControl(items: ["A", "B"])
|
let segmenetedControl = UISegmentedControl(items: ["A", "B"])
|
||||||
segmenetedControl.selectedSegmentIndex = 0
|
segmenetedControl.selectedSegmentIndex = 0
|
||||||
|
@ -31,7 +31,7 @@ final class ProfileHeaderViewController: UIViewController {
|
||||||
private var isBannerPinned = false
|
private var isBannerPinned = false
|
||||||
private var bottomShadowAlpha: CGFloat = 0.0
|
private var bottomShadowAlpha: CGFloat = 0.0
|
||||||
|
|
||||||
private var isAdjustBannerImageViewForSafeAreaInset = false
|
// private var isAdjustBannerImageViewForSafeAreaInset = false
|
||||||
private var containerSafeAreaInset: UIEdgeInsets = .zero
|
private var containerSafeAreaInset: UIEdgeInsets = .zero
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
@ -47,19 +47,19 @@ extension ProfileHeaderViewController {
|
||||||
|
|
||||||
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
|
||||||
profileBannerView.translatesAutoresizingMaskIntoConstraints = false
|
profileHeaderView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(profileBannerView)
|
view.addSubview(profileHeaderView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
profileBannerView.topAnchor.constraint(equalTo: view.topAnchor),
|
profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
profileBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
profileBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
])
|
])
|
||||||
profileBannerView.preservesSuperviewLayoutMargins = true
|
profileHeaderView.preservesSuperviewLayoutMargins = true
|
||||||
|
|
||||||
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
|
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(pageSegmentedControl)
|
view.addSubview(pageSegmentedControl)
|
||||||
NSLayoutConstraint.activate([
|
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.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
||||||
pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
||||||
view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight),
|
view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight),
|
||||||
|
@ -72,11 +72,13 @@ extension ProfileHeaderViewController {
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
if !isAdjustBannerImageViewForSafeAreaInset {
|
// Deprecated:
|
||||||
isAdjustBannerImageViewForSafeAreaInset = true
|
// not needs this tweak due to force layout update in the parent
|
||||||
profileBannerView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top
|
// if !isAdjustBannerImageViewForSafeAreaInset {
|
||||||
profileBannerView.bannerImageView.frame.size.height += containerSafeAreaInset.top
|
// isAdjustBannerImageViewForSafeAreaInset = true
|
||||||
}
|
// profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top
|
||||||
|
// profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
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)
|
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
|
||||||
updateHeaderBottomShadow(progress: progress)
|
updateHeaderBottomShadow(progress: progress)
|
||||||
|
|
||||||
let bannerImageView = profileBannerView.bannerImageView
|
let bannerImageView = profileHeaderView.bannerImageView
|
||||||
guard bannerImageView.bounds != .zero else {
|
guard bannerImageView.bounds != .zero else {
|
||||||
// wait layout finish
|
// wait layout finish
|
||||||
return
|
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
|
let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height
|
||||||
|
|
||||||
if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {
|
if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
||||||
import ActiveLabel
|
import ActiveLabel
|
||||||
|
|
||||||
protocol ProfileHeaderViewDelegate: class {
|
protocol ProfileHeaderViewDelegate: class {
|
||||||
|
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
|
||||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
|
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
|
||||||
|
|
||||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
|
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 avatarImageViewSize = CGSize(width: 56, height: 56)
|
||||||
static let avatarImageViewCornerRadius: CGFloat = 6
|
static let avatarImageViewCornerRadius: CGFloat = 6
|
||||||
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
|
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
|
||||||
|
static let bannerImageViewPlaceholderColor = UIColor.systemGray
|
||||||
|
|
||||||
weak var delegate: ProfileHeaderViewDelegate?
|
weak var delegate: ProfileHeaderViewDelegate?
|
||||||
|
|
||||||
|
@ -29,10 +31,19 @@ final class ProfileHeaderView: UIView {
|
||||||
let bannerImageView: UIImageView = {
|
let bannerImageView: UIImageView = {
|
||||||
let imageView = UIImageView()
|
let imageView = UIImageView()
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
imageView.image = .placeholder(color: .systemGray)
|
imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor)
|
||||||
|
imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor
|
||||||
imageView.layer.masksToBounds = true
|
imageView.layer.masksToBounds = true
|
||||||
|
// #if DEBUG
|
||||||
|
// imageView.image = .placeholder(color: .red)
|
||||||
|
// #endif
|
||||||
return imageView
|
return imageView
|
||||||
}()
|
}()
|
||||||
|
let bannerImageViewOverlayView: UIView = {
|
||||||
|
let overlayView = UIView()
|
||||||
|
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||||
|
return overlayView
|
||||||
|
}()
|
||||||
|
|
||||||
let avatarImageView: UIImageView = {
|
let avatarImageView: UIImageView = {
|
||||||
let imageView = 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.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
|
||||||
label.adjustsFontSizeToFitWidth = true
|
label.adjustsFontSizeToFitWidth = true
|
||||||
label.minimumScaleFactor = 0.5
|
label.minimumScaleFactor = 0.5
|
||||||
label.textColor = .white
|
label.textColor = Asset.Profile.Banner.usernameGray.color
|
||||||
label.text = "@alice"
|
label.text = "@alice"
|
||||||
label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
|
label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let statusDashboardView = ProfileStatusDashboardView()
|
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 bioContainerView = UIView()
|
||||||
let fieldContainerStackView = UIStackView()
|
let fieldContainerStackView = UIStackView()
|
||||||
|
@ -104,6 +119,15 @@ extension ProfileHeaderView {
|
||||||
bannerImageView.frame = bannerContainerView.bounds
|
bannerImageView.frame = bannerContainerView.bounds
|
||||||
bannerContainerView.addSubview(bannerImageView)
|
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
|
// avatar
|
||||||
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
bannerContainerView.addSubview(avatarImageView)
|
bannerContainerView.addSubview(avatarImageView)
|
||||||
|
@ -156,14 +180,14 @@ extension ProfileHeaderView {
|
||||||
statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor),
|
statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
friendshipActionButton.translatesAutoresizingMaskIntoConstraints = false
|
relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
dashboardContainerView.addSubview(friendshipActionButton)
|
dashboardContainerView.addSubview(relationshipActionButton)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
friendshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor),
|
relationshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor),
|
||||||
friendshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8),
|
relationshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8),
|
||||||
friendshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor),
|
relationshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor),
|
||||||
friendshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh),
|
relationshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh),
|
||||||
friendshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh),
|
relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh),
|
||||||
])
|
])
|
||||||
|
|
||||||
bioContainerView.preservesSuperviewLayoutMargins = true
|
bioContainerView.preservesSuperviewLayoutMargins = true
|
||||||
|
@ -184,10 +208,20 @@ extension ProfileHeaderView {
|
||||||
bringSubviewToFront(nameContainerStackView)
|
bringSubviewToFront(nameContainerStackView)
|
||||||
|
|
||||||
bioActiveLabel.delegate = self
|
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
|
// MARK: - ActiveLabelDelegate
|
||||||
extension ProfileHeaderView: ActiveLabelDelegate {
|
extension ProfileHeaderView: ActiveLabelDelegate {
|
||||||
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
|
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -18,11 +18,17 @@ final class ProfileViewController: UIViewController, NeedsDependency {
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
var viewModel: ProfileViewModel!
|
var viewModel: ProfileViewModel!
|
||||||
|
|
||||||
private var preferredStatusBarStyleForBanner: UIStatusBarStyle = .lightContent {
|
private(set) lazy var replyBarButtonItem: UIBarButtonItem = {
|
||||||
didSet {
|
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:)))
|
||||||
setNeedsStatusBarAppearanceUpdate()
|
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 = {
|
||||||
let refreshControl = UIRefreshControl()
|
let refreshControl = UIRefreshControl()
|
||||||
|
@ -78,7 +84,7 @@ extension ProfileViewController {
|
||||||
height: bottomPageHeight + headerViewHeight
|
height: bottomPageHeight + headerViewHeight
|
||||||
)
|
)
|
||||||
self.overlayScrollView.contentSize = contentSize
|
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 {
|
extension ProfileViewController {
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
return preferredStatusBarStyleForBanner
|
return .lightContent
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewSafeAreaInsetsDidChange() {
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
|
@ -95,6 +101,10 @@ extension ProfileViewController {
|
||||||
profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets)
|
profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var isViewLoaded: Bool {
|
||||||
|
return super.isViewLoaded
|
||||||
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
@ -105,72 +115,42 @@ extension ProfileViewController {
|
||||||
navigationItem.standardAppearance = barAppearance
|
navigationItem.standardAppearance = barAppearance
|
||||||
navigationItem.compactAppearance = barAppearance
|
navigationItem.compactAppearance = barAppearance
|
||||||
navigationItem.scrollEdgeAppearance = barAppearance
|
navigationItem.scrollEdgeAppearance = barAppearance
|
||||||
|
|
||||||
navigationItem.titleView = UIView()
|
navigationItem.titleView = UIView()
|
||||||
|
|
||||||
// if navigationController?.viewControllers.first == self {
|
Publishers.CombineLatest(
|
||||||
// navigationItem.leftBarButtonItem = avatarBarButtonItem
|
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
|
||||||
// }
|
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
|
||||||
// avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside)
|
)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
// unmuteMenuBarButtonItem.target = self
|
.sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
|
||||||
// unmuteMenuBarButtonItem.action = #selector(ProfileViewController.unmuteBarButtonItemPressed(_:))
|
guard let self = self else { return }
|
||||||
|
var items: [UIBarButtonItem] = []
|
||||||
// Publishers.CombineLatest4(
|
if !isReplyBarButtonItemHidden {
|
||||||
// viewModel.muted.eraseToAnyPublisher(),
|
items.append(self.replyBarButtonItem)
|
||||||
// viewModel.blocked.eraseToAnyPublisher(),
|
}
|
||||||
// viewModel.twitterUser.eraseToAnyPublisher(),
|
if !isMoreMenuBarButtonItemHidden {
|
||||||
// context.authenticationService.activeTwitterAuthenticationBox.eraseToAnyPublisher()
|
items.append(self.moreMenuBarButtonItem)
|
||||||
// )
|
}
|
||||||
// .receive(on: DispatchQueue.main)
|
guard !items.isEmpty else {
|
||||||
// .sink { [weak self] muted, blocked, twitterUser, activeTwitterAuthenticationBox in
|
self.navigationItem.rightBarButtonItems = nil
|
||||||
// guard let self = self else { return }
|
return
|
||||||
// guard let twitterUser = twitterUser,
|
}
|
||||||
// let activeTwitterAuthenticationBox = activeTwitterAuthenticationBox,
|
self.navigationItem.rightBarButtonItems = items
|
||||||
// twitterUser.id != activeTwitterAuthenticationBox.twitterUserID else {
|
}
|
||||||
// self.navigationItem.rightBarButtonItems = []
|
.store(in: &disposeBag)
|
||||||
// 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)
|
|
||||||
|
|
||||||
overlayScrollView.refreshControl = refreshControl
|
overlayScrollView.refreshControl = refreshControl
|
||||||
refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged)
|
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())
|
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)
|
bind(userTimelineViewModel: postsUserTimelineViewModel)
|
||||||
viewModel.userID.assign(to: \.value, on: postsUserTimelineViewModel.userID).store(in: &disposeBag)
|
|
||||||
|
|
||||||
let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true))
|
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)
|
bind(userTimelineViewModel: repliesUserTimelineViewModel)
|
||||||
viewModel.userID.assign(to: \.value, on: repliesUserTimelineViewModel.userID).store(in: &disposeBag)
|
|
||||||
|
|
||||||
let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true))
|
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)
|
bind(userTimelineViewModel: mediaUserTimelineViewModel)
|
||||||
viewModel.userID.assign(to: \.value, on: mediaUserTimelineViewModel.userID).store(in: &disposeBag)
|
|
||||||
|
|
||||||
profileSegmentedViewController.pagingViewController.viewModel = {
|
profileSegmentedViewController.pagingViewController.viewModel = {
|
||||||
let profilePagingViewModel = ProfilePagingViewModel(
|
let profilePagingViewModel = ProfilePagingViewModel(
|
||||||
|
@ -244,23 +224,15 @@ extension ProfileViewController {
|
||||||
profileHeaderViewController.delegate = self
|
profileHeaderViewController.delegate = self
|
||||||
profileSegmentedViewController.pagingViewController.pagingDelegate = 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
|
// 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(
|
Publishers.CombineLatest(
|
||||||
viewModel.bannerImageURL.eraseToAnyPublisher(),
|
viewModel.bannerImageURL.eraseToAnyPublisher(),
|
||||||
viewModel.viewDidAppear.eraseToAnyPublisher()
|
viewModel.viewDidAppear.eraseToAnyPublisher()
|
||||||
|
@ -268,56 +240,29 @@ extension ProfileViewController {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] bannerImageURL, _ in
|
.sink { [weak self] bannerImageURL, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.profileHeaderViewController.profileBannerView.bannerImageView.af.cancelImageRequest()
|
self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest()
|
||||||
let placeholder = UIImage.placeholder(color: Asset.Colors.Background.systemGroupedBackground.color)
|
let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor)
|
||||||
guard let bannerImageURL = bannerImageURL else {
|
guard let bannerImageURL = bannerImageURL else {
|
||||||
self.profileHeaderViewController.profileBannerView.bannerImageView.image = placeholder
|
self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.profileHeaderViewController.profileBannerView.bannerImageView.af.setImage(
|
self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage(
|
||||||
withURL: bannerImageURL,
|
withURL: bannerImageURL,
|
||||||
placeholderImage: placeholder,
|
placeholderImage: placeholder,
|
||||||
imageTransition: .crossDissolve(0.3),
|
imageTransition: .crossDissolve(0.3),
|
||||||
runImageTransitionIfCached: false,
|
runImageTransitionIfCached: false,
|
||||||
completion: { [weak self] response in
|
completion: { [weak self] response in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
switch response.result {
|
guard let image = response.value else { return }
|
||||||
case .success(let image):
|
guard image.size.width > 1 && image.size.height > 1 else {
|
||||||
self.viewModel.headerDomainLumaStyle.value = image.domainLumaCoefficientsStyle ?? .dark
|
// restore to placeholder when image invalid
|
||||||
case .failure:
|
self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder
|
||||||
break
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.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(
|
Publishers.CombineLatest(
|
||||||
viewModel.avatarImageURL.eraseToAnyPublisher(),
|
viewModel.avatarImageURL.eraseToAnyPublisher(),
|
||||||
viewModel.viewDidAppear.eraseToAnyPublisher()
|
viewModel.viewDidAppear.eraseToAnyPublisher()
|
||||||
|
@ -325,142 +270,95 @@ extension ProfileViewController {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] avatarImageURL, _ in
|
.sink { [weak self] avatarImageURL, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.profileHeaderViewController.profileBannerView.configure(
|
self.profileHeaderViewController.profileHeaderView.configure(
|
||||||
with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL)
|
with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
// viewModel.protected
|
|
||||||
// .map { $0 != true }
|
|
||||||
// .assign(to: \.isHidden, on: profileHeaderViewController.profileBannerView.lockImageView)
|
|
||||||
// .store(in: &disposeBag)
|
|
||||||
viewModel.name
|
viewModel.name
|
||||||
.map { $0 ?? " " }
|
.map { $0 ?? " " }
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: \.text, on: profileHeaderViewController.profileBannerView.nameLabel)
|
.assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
viewModel.username
|
viewModel.username
|
||||||
.map { username in username.flatMap { "@" + $0 } ?? " " }
|
.map { username in username.flatMap { "@" + $0 } ?? " " }
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: \.text, on: profileHeaderViewController.profileBannerView.usernameLabel)
|
.assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
// viewModel.friendship
|
viewModel.relationshipActionOptionSet
|
||||||
// .sink { [weak self] friendship in
|
.receive(on: DispatchQueue.main)
|
||||||
// guard let self = self else { return }
|
.sink { [weak self] relationshipActionOptionSet in
|
||||||
// let followingButton = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followActionButton
|
guard let self = self else { return }
|
||||||
// followingButton.isHidden = friendship == nil
|
guard let mastodonUser = self.viewModel.mastodonUser.value else {
|
||||||
//
|
self.moreMenuBarButtonItem.menu = nil
|
||||||
// if let friendship = friendship {
|
return
|
||||||
// switch friendship {
|
}
|
||||||
// case .following: followingButton.style = .following
|
let isMuting = relationshipActionOptionSet.contains(.muting)
|
||||||
// case .pending: followingButton.style = .pending
|
let isBlocking = relationshipActionOptionSet.contains(.blocking)
|
||||||
// case .none: followingButton.style = .follow
|
self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self)
|
||||||
// }
|
}
|
||||||
// }
|
.store(in: &disposeBag)
|
||||||
// }
|
viewModel.isRelationshipActionButtonHidden
|
||||||
// .store(in: &disposeBag)
|
.receive(on: DispatchQueue.main)
|
||||||
// viewModel.followedBy
|
.sink { [weak self] isHidden in
|
||||||
// .sink { [weak self] followedBy in
|
guard let self = self else { return }
|
||||||
// guard let self = self else { return }
|
self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden
|
||||||
// let followStatusLabel = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followStatusLabel
|
}
|
||||||
// followStatusLabel.isHidden = followedBy != true
|
.store(in: &disposeBag)
|
||||||
// }
|
Publishers.CombineLatest(
|
||||||
// .store(in: &disposeBag)
|
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
|
viewModel.bioDescription
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink(receiveValue: { [weak self] bio in
|
.sink(receiveValue: { [weak self] bio in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.profileHeaderViewController.profileBannerView.bioActiveLabel.configure(note: bio ?? "")
|
self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "")
|
||||||
})
|
})
|
||||||
.store(in: &disposeBag)
|
.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
|
viewModel.statusesCount
|
||||||
.sink { [weak self] count in
|
.sink { [weak self] count in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let text = count.flatMap { String($0) } ?? "-"
|
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
|
||||||
self.profileHeaderViewController.profileBannerView.statusDashboardView.postDashboardMeterView.numberLabel.text = text
|
self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
viewModel.followingCount
|
viewModel.followingCount
|
||||||
.sink { [weak self] count in
|
.sink { [weak self] count in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let text = count.flatMap { String($0) } ?? "-"
|
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
|
||||||
self.profileHeaderViewController.profileBannerView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text
|
self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
viewModel.followersCount
|
viewModel.followersCount
|
||||||
.sink { [weak self] count in
|
.sink { [weak self] count in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let text = count.flatMap { String($0) } ?? "-"
|
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
|
||||||
self.profileHeaderViewController.profileBannerView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text
|
self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.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.profileHeaderView.delegate = self
|
||||||
profileHeaderViewController.profileBannerView.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) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
@ -483,6 +381,27 @@ extension ProfileViewController {
|
||||||
|
|
||||||
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) {
|
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
@ -500,43 +419,6 @@ extension ProfileViewController {
|
||||||
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController))
|
// 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,60 +482,95 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate {
|
||||||
// setup observer and gesture fallback
|
// setup observer and gesture fallback
|
||||||
currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView)
|
currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView)
|
||||||
postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer)
|
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
|
// MARK: - ProfileHeaderViewDelegate
|
||||||
extension ProfileViewController: 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, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
|
||||||
|
|
|
@ -26,14 +26,12 @@ class ProfileViewModel: NSObject {
|
||||||
let mastodonUser: CurrentValueSubject<MastodonUser?, Never>
|
let mastodonUser: CurrentValueSubject<MastodonUser?, Never>
|
||||||
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
|
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
|
||||||
let viewDidAppear = PassthroughSubject<Void, Never>()
|
let viewDidAppear = PassthroughSubject<Void, Never>()
|
||||||
let headerDomainLumaStyle = CurrentValueSubject<UIUserInterfaceStyle, Never>(.dark) // default dark for placeholder banner
|
|
||||||
|
|
||||||
// output
|
// output
|
||||||
let domain: CurrentValueSubject<String?, Never>
|
let domain: CurrentValueSubject<String?, Never>
|
||||||
let userID: CurrentValueSubject<UserID?, Never>
|
let userID: CurrentValueSubject<UserID?, Never>
|
||||||
let bannerImageURL: CurrentValueSubject<URL?, Never>
|
let bannerImageURL: CurrentValueSubject<URL?, Never>
|
||||||
let avatarImageURL: CurrentValueSubject<URL?, Never>
|
let avatarImageURL: CurrentValueSubject<URL?, Never>
|
||||||
// let protected: CurrentValueSubject<Bool?, Never>
|
|
||||||
let name: CurrentValueSubject<String?, Never>
|
let name: CurrentValueSubject<String?, Never>
|
||||||
let username: CurrentValueSubject<String?, Never>
|
let username: CurrentValueSubject<String?, Never>
|
||||||
let bioDescription: CurrentValueSubject<String?, Never>
|
let bioDescription: CurrentValueSubject<String?, Never>
|
||||||
|
@ -42,13 +40,19 @@ class ProfileViewModel: NSObject {
|
||||||
let followingCount: CurrentValueSubject<Int?, Never>
|
let followingCount: CurrentValueSubject<Int?, Never>
|
||||||
let followersCount: CurrentValueSubject<Int?, Never>
|
let followersCount: CurrentValueSubject<Int?, Never>
|
||||||
|
|
||||||
// let friendship: CurrentValueSubject<Friendship?, Never>
|
let protected: CurrentValueSubject<Bool?, Never>
|
||||||
// let followedBy: CurrentValueSubject<Bool?, Never>
|
// let suspended: CurrentValueSubject<Bool, Never>
|
||||||
// let muted: CurrentValueSubject<Bool, Never>
|
|
||||||
// let blocked: CurrentValueSubject<Bool, Never>
|
let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
|
||||||
//
|
let isEditing = CurrentValueSubject<Bool, Never>(false)
|
||||||
// let suspended = 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?) {
|
init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
|
||||||
self.context = context
|
self.context = context
|
||||||
|
@ -65,12 +69,15 @@ class ProfileViewModel: NSObject {
|
||||||
self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) })
|
self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) })
|
||||||
self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) })
|
self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) })
|
||||||
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
|
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
|
||||||
// self.friendship = CurrentValueSubject(nil)
|
self.protected = CurrentValueSubject(mastodonUser?.locked)
|
||||||
// self.followedBy = CurrentValueSubject(nil)
|
|
||||||
// self.muted = CurrentValueSubject(false)
|
|
||||||
// self.blocked = CurrentValueSubject(false)
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
|
relationshipActionOptionSet
|
||||||
|
.compactMap { $0.highPriorityAction(except: []) }
|
||||||
|
.map { $0 == .none }
|
||||||
|
.assign(to: \.value, on: isRelationshipActionButtonHidden)
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
// bind active authentication
|
// bind active authentication
|
||||||
context.authenticationService.activeMastodonAuthentication
|
context.authenticationService.activeMastodonAuthentication
|
||||||
.sink { [weak self] activeMastodonAuthentication in
|
.sink { [weak self] activeMastodonAuthentication in
|
||||||
|
@ -85,25 +92,53 @@ class ProfileViewModel: NSObject {
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
setup()
|
// 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)
|
||||||
|
|
||||||
extension ProfileViewModel {
|
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)
|
||||||
|
|
||||||
enum Friendship: CustomDebugStringConvertible {
|
// there are seconds delay after request follow before requested -> following. Query again when needs
|
||||||
case following
|
guard let relationship = response.value.first else { return }
|
||||||
case pending
|
if relationship.requested == true {
|
||||||
case none
|
let delay = pendingRetryPublisher.value
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
var debugDescription: String {
|
guard let _ = self else { return }
|
||||||
switch self {
|
pendingRetryPublisher.value = min(2 * delay, 60)
|
||||||
case .following: return "following"
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
case .pending: return "pending"
|
}
|
||||||
case .none: return "none"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -117,9 +152,11 @@ extension ProfileViewModel {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] mastodonUser, currentMastodonUser in
|
.sink { [weak self] mastodonUser, currentMastodonUser in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
// Update view model attribute
|
||||||
self.update(mastodonUser: mastodonUser)
|
self.update(mastodonUser: mastodonUser)
|
||||||
self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
|
self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
|
||||||
|
|
||||||
|
// Setup observer for user
|
||||||
if let mastodonUser = mastodonUser {
|
if let mastodonUser = mastodonUser {
|
||||||
// setup observer
|
// setup observer
|
||||||
self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser)
|
self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser)
|
||||||
|
@ -147,6 +184,7 @@ extension ProfileViewModel {
|
||||||
self.mastodonUserObserver = nil
|
self.mastodonUserObserver = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup observer for user
|
||||||
if let currentMastodonUser = currentMastodonUser {
|
if let currentMastodonUser = currentMastodonUser {
|
||||||
// setup observer
|
// setup observer
|
||||||
self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser)
|
self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser)
|
||||||
|
@ -179,7 +217,6 @@ extension ProfileViewModel {
|
||||||
self.userID.value = mastodonUser?.id
|
self.userID.value = mastodonUser?.id
|
||||||
self.bannerImageURL.value = mastodonUser?.headerImageURL()
|
self.bannerImageURL.value = mastodonUser?.headerImageURL()
|
||||||
self.avatarImageURL.value = mastodonUser?.avatarImageURL()
|
self.avatarImageURL.value = mastodonUser?.avatarImageURL()
|
||||||
// self.protected.value = twitterUser?.protected
|
|
||||||
self.name.value = mastodonUser?.displayNameWithFallback
|
self.name.value = mastodonUser?.displayNameWithFallback
|
||||||
self.username.value = mastodonUser?.acctWithDomain
|
self.username.value = mastodonUser?.acctWithDomain
|
||||||
self.bioDescription.value = mastodonUser?.note
|
self.bioDescription.value = mastodonUser?.note
|
||||||
|
@ -187,11 +224,159 @@ extension ProfileViewModel {
|
||||||
self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) }
|
self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) }
|
||||||
self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) }
|
self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) }
|
||||||
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
|
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
|
||||||
|
self.protected.value = mastodonUser?.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency {
|
||||||
let tableView = UITableView()
|
let tableView = UITableView()
|
||||||
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||||
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||||
|
tableView.register(TimelineHeaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineHeaderTableViewCell.self))
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.backgroundColor = .clear
|
tableView.backgroundColor = .clear
|
||||||
|
@ -100,9 +101,29 @@ extension UserTimelineViewController {
|
||||||
// MARK: - UITableViewDelegate
|
// MARK: - UITableViewDelegate
|
||||||
extension UserTimelineViewController: UITableViewDelegate {
|
extension UserTimelineViewController: UITableViewDelegate {
|
||||||
|
|
||||||
// TODO: cache cell height
|
|
||||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,8 +31,6 @@ extension UserTimelineViewModel.State {
|
||||||
switch stateClass {
|
switch stateClass {
|
||||||
case is Reloading.Type:
|
case is Reloading.Type:
|
||||||
return viewModel.userID.value != nil
|
return viewModel.userID.value != nil
|
||||||
case is Suspended.Type:
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -48,10 +46,6 @@ extension UserTimelineViewModel.State {
|
||||||
return true
|
return true
|
||||||
case is NoMore.Type:
|
case is NoMore.Type:
|
||||||
return true
|
return true
|
||||||
case is NotAuthorized.Type, is Blocked.Type:
|
|
||||||
return true
|
|
||||||
case is Suspended.Type:
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -116,8 +110,6 @@ extension UserTimelineViewModel.State {
|
||||||
switch stateClass {
|
switch stateClass {
|
||||||
case is Reloading.Type, is LoadingMore.Type:
|
case is Reloading.Type, is LoadingMore.Type:
|
||||||
return true
|
return true
|
||||||
case is Suspended.Type:
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -129,8 +121,6 @@ extension UserTimelineViewModel.State {
|
||||||
switch stateClass {
|
switch stateClass {
|
||||||
case is Reloading.Type, is LoadingMore.Type:
|
case is Reloading.Type, is LoadingMore.Type:
|
||||||
return true
|
return true
|
||||||
case is Suspended.Type:
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -146,10 +136,6 @@ extension UserTimelineViewModel.State {
|
||||||
return true
|
return true
|
||||||
case is NoMore.Type:
|
case is NoMore.Type:
|
||||||
return true
|
return true
|
||||||
case is NotAuthorized.Type, is Blocked.Type:
|
|
||||||
return true
|
|
||||||
case is Suspended.Type:
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -188,7 +174,12 @@ extension UserTimelineViewModel.State {
|
||||||
)
|
)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { completion in
|
.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
|
} receiveValue: { response in
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
@ -211,52 +202,22 @@ extension UserTimelineViewModel.State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
class NoMore: UserTimelineViewModel.State {
|
||||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
switch stateClass {
|
switch stateClass {
|
||||||
case is Reloading.Type:
|
case is Reloading.Type:
|
||||||
return true
|
return true
|
||||||
case is NotAuthorized.Type, is Blocked.Type:
|
|
||||||
return true
|
|
||||||
case is Suspended.Type:
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let _ = stateMachine else { return }
|
||||||
|
|
||||||
|
// trigger data source update
|
||||||
|
viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,10 @@ class UserTimelineViewModel: NSObject {
|
||||||
let userID: CurrentValueSubject<String?, Never>
|
let userID: CurrentValueSubject<String?, Never>
|
||||||
let queryFilter: CurrentValueSubject<QueryFilter, Never>
|
let queryFilter: CurrentValueSubject<QueryFilter, Never>
|
||||||
let statusFetchedResultsController: StatusFetchedResultsController
|
let statusFetchedResultsController: StatusFetchedResultsController
|
||||||
|
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||||
|
|
||||||
|
let isBlocking = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
|
||||||
// output
|
// output
|
||||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||||
|
@ -34,9 +38,6 @@ class UserTimelineViewModel: NSObject {
|
||||||
State.Fail(viewModel: self),
|
State.Fail(viewModel: self),
|
||||||
State.Idle(viewModel: self),
|
State.Idle(viewModel: self),
|
||||||
State.LoadingMore(viewModel: self),
|
State.LoadingMore(viewModel: self),
|
||||||
State.NotAuthorized(viewModel: self),
|
|
||||||
State.Blocked(viewModel: self),
|
|
||||||
State.Suspended(viewModel: self),
|
|
||||||
State.NoMore(viewModel: self),
|
State.NoMore(viewModel: self),
|
||||||
])
|
])
|
||||||
stateMachine.enter(State.Initial.self)
|
stateMachine.enter(State.Initial.self)
|
||||||
|
@ -59,46 +60,64 @@ class UserTimelineViewModel: NSObject {
|
||||||
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|
||||||
statusFetchedResultsController.objectIDs
|
var items: [Item] = []
|
||||||
.receive(on: DispatchQueue.main)
|
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||||
.sink { [weak self] objectIDs in
|
snapshot.appendSections([.main])
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
defer {
|
||||||
// not animate when empty items fix loader first appear layout issue
|
// not animate when empty items fix loader first appear layout issue
|
||||||
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
|
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 {
|
deinit {
|
||||||
|
@ -125,3 +144,4 @@ extension UserTimelineViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -70,7 +70,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
extension SearchRecommendAccountsCollectionViewCell {
|
extension SearchRecommendAccountsCollectionViewCell {
|
||||||
private func configure() {
|
private func configure() {
|
||||||
headerImageView.backgroundColor = Asset.Colors.buttonDefault.color
|
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
|
||||||
layer.cornerRadius = 8
|
layer.cornerRadius = 8
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
|
|
||||||
|
|
|
@ -59,7 +59,7 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
extension SearchRecommendTagsCollectionViewCell {
|
extension SearchRecommendTagsCollectionViewCell {
|
||||||
private func configure() {
|
private func configure() {
|
||||||
backgroundColor = Asset.Colors.buttonDefault.color
|
backgroundColor = Asset.Colors.brandBlue.color
|
||||||
layer.cornerRadius = 8
|
layer.cornerRadius = 8
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ final class SearchViewController: UIViewController, NeedsDependency {
|
||||||
let searchBar: UISearchBar = {
|
let searchBar: UISearchBar = {
|
||||||
let searchBar = UISearchBar()
|
let searchBar = UISearchBar()
|
||||||
searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder
|
searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder
|
||||||
searchBar.tintColor = Asset.Colors.buttonDefault.color
|
searchBar.tintColor = Asset.Colors.brandBlue.color
|
||||||
searchBar.translatesAutoresizingMaskIntoConstraints = false
|
searchBar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
let micImage = UIImage(systemName: "mic.fill")
|
let micImage = UIImage(systemName: "mic.fill")
|
||||||
searchBar.setImage(micImage, for: .bookmark, state: .normal)
|
searchBar.setImage(micImage, for: .bookmark, state: .normal)
|
||||||
|
|
|
@ -27,8 +27,8 @@ class SearchRecommendCollectionHeader: UIView {
|
||||||
|
|
||||||
let seeAllButton: UIButton = {
|
let seeAllButton: UIButton = {
|
||||||
let button = UIButton(type: .custom)
|
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)
|
button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal)
|
||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ class NavigationBarProgressView: UIView {
|
||||||
|
|
||||||
let sliderView: UIView = {
|
let sliderView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.backgroundColor = Asset.Colors.buttonDefault.color
|
view.backgroundColor = Asset.Colors.brandBlue.color
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
|
@ -17,6 +17,7 @@ protocol StatusViewDelegate: class {
|
||||||
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
|
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
|
||||||
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||||
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
||||||
|
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class StatusView: UIView {
|
final class StatusView: UIView {
|
||||||
|
@ -402,6 +403,7 @@ extension StatusView {
|
||||||
statusContentWarningContainerStackView.isHidden = true
|
statusContentWarningContainerStackView.isHidden = true
|
||||||
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
|
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
|
||||||
|
|
||||||
|
activeTextLabel.delegate = self
|
||||||
playerContainerView.delegate = self
|
playerContainerView.delegate = self
|
||||||
|
|
||||||
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
|
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
|
||||||
|
@ -475,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
|
// MARK: - PlayerContainerViewDelegate
|
||||||
extension StatusView: PlayerContainerViewDelegate {
|
extension StatusView: PlayerContainerViewDelegate {
|
||||||
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||||
|
|
|
@ -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
|
|
@ -11,6 +11,7 @@ import AVKit
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
import ActiveLabel
|
||||||
|
|
||||||
protocol StatusTableViewCellDelegate: class {
|
protocol StatusTableViewCellDelegate: class {
|
||||||
var context: AppContext! { get }
|
var context: AppContext! { get }
|
||||||
|
@ -18,18 +19,22 @@ protocol StatusTableViewCellDelegate: class {
|
||||||
|
|
||||||
func parent() -> UIViewController
|
func parent() -> UIViewController
|
||||||
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get }
|
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, headerInfoLabelDidPressed label: UILabel)
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton)
|
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton)
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed 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, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
|
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, 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, reblogButtonDidPressed sender: UIButton)
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed 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, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -216,6 +221,10 @@ extension StatusTableViewCell: StatusViewDelegate {
|
||||||
delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button)
|
delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
|
||||||
|
delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MosaicImageViewDelegate
|
// MARK: - MosaicImageViewDelegate
|
||||||
|
|
|
@ -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),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -67,7 +67,7 @@ class TimelineLoaderTableViewCell: UITableViewCell {
|
||||||
func stopAnimating() {
|
func stopAnimating() {
|
||||||
activityIndicatorView.stopAnimating()
|
activityIndicatorView.stopAnimating()
|
||||||
self.loadMoreButton.isEnabled = true
|
self.loadMoreButton.isEnabled = true
|
||||||
self.loadMoreLabel.textColor = Asset.Colors.buttonDefault.color
|
self.loadMoreLabel.textColor = Asset.Colors.brandBlue.color
|
||||||
self.loadMoreLabel.text = ""
|
self.loadMoreLabel.text = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -163,7 +163,7 @@ extension ActionToolbarContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) {
|
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.tintColor = tintColor
|
||||||
reblogButton.setTitleColor(tintColor, for: .normal)
|
reblogButton.setTitleColor(tintColor, for: .normal)
|
||||||
reblogButton.setTitleColor(tintColor, for: .highlighted)
|
reblogButton.setTitleColor(tintColor, for: .highlighted)
|
||||||
|
|
|
@ -10,6 +10,52 @@ import Combine
|
||||||
import CommonOSLog
|
import CommonOSLog
|
||||||
import MastodonSDK
|
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 {
|
extension APIService {
|
||||||
|
|
||||||
func accountVerifyCredentials(
|
func accountVerifyCredentials(
|
||||||
|
@ -33,12 +79,20 @@ extension APIService {
|
||||||
entity: account,
|
entity: account,
|
||||||
userCache: nil,
|
userCache: nil,
|
||||||
networkDate: response.networkDate,
|
networkDate: response.networkDate,
|
||||||
log: log)
|
log: log
|
||||||
|
)
|
||||||
let flag = isCreated ? "+" : "-"
|
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)
|
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)
|
.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()
|
||||||
}
|
}
|
||||||
.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)
|
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)
|
.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()
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
//
|
||||||
|
// APIService+HashtagTimeline.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import CommonOSLog
|
||||||
|
import DateToolsSwift
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension APIService {
|
||||||
|
|
||||||
|
func hashtagTimeline(
|
||||||
|
domain: String,
|
||||||
|
sinceID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
maxID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
limit: Int = onceRequestStatusMaxCount,
|
||||||
|
local: Bool? = nil,
|
||||||
|
hashtag: String,
|
||||||
|
authorizationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||||
|
let authorization = authorizationBox.userAuthorization
|
||||||
|
let requestMastodonUserID = authorizationBox.userID
|
||||||
|
let query = Mastodon.API.Timeline.HashtagTimelineQuery(
|
||||||
|
maxID: maxID,
|
||||||
|
sinceID: sinceID,
|
||||||
|
minID: nil, // prefer sinceID
|
||||||
|
limit: limit,
|
||||||
|
local: local,
|
||||||
|
onlyMedia: false
|
||||||
|
)
|
||||||
|
|
||||||
|
return Mastodon.API.Timeline.hashtag(
|
||||||
|
session: session,
|
||||||
|
domain: domain,
|
||||||
|
query: query,
|
||||||
|
hashtag: hashtag,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||||
|
return APIService.Persist.persistStatus(
|
||||||
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
|
domain: domain,
|
||||||
|
query: query,
|
||||||
|
response: response,
|
||||||
|
persistType: .lookUp,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
// Created by MainasuK Cirno on 2021-4-1.
|
// Created by MainasuK Cirno on 2021-4-1.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
@ -19,47 +19,47 @@ extension APIService {
|
||||||
accountIDs: [Mastodon.Entity.Account.ID],
|
accountIDs: [Mastodon.Entity.Account.ID],
|
||||||
authorizationBox: AuthenticationService.MastodonAuthenticationBox
|
authorizationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> {
|
||||||
fatalError()
|
let authorization = authorizationBox.userAuthorization
|
||||||
// let authorization = authorizationBox.userAuthorization
|
let requestMastodonUserID = authorizationBox.userID
|
||||||
// let requestMastodonUserID = authorizationBox.userID
|
let query = Mastodon.API.Account.RelationshipQuery(
|
||||||
// let query = Mastodon.API.Account.AccountStatuseseQuery(
|
ids: accountIDs
|
||||||
// maxID: maxID,
|
)
|
||||||
// sinceID: sinceID,
|
|
||||||
// excludeReplies: excludeReplies,
|
return Mastodon.API.Account.relationships(
|
||||||
// excludeReblogs: excludeReblogs,
|
session: session,
|
||||||
// onlyMedia: onlyMedia,
|
domain: domain,
|
||||||
// limit: limit
|
query: query,
|
||||||
// )
|
authorization: authorization
|
||||||
//
|
)
|
||||||
// return Mastodon.API.Account.statuses(
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> in
|
||||||
// session: session,
|
let managedObjectContext = self.backgroundManagedObjectContext
|
||||||
// domain: domain,
|
return managedObjectContext.performChanges {
|
||||||
// accountID: accountID,
|
let requestMastodonUserRequest = MastodonUser.sortedFetchRequest
|
||||||
// query: query,
|
requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
|
||||||
// authorization: authorization
|
requestMastodonUserRequest.fetchLimit = 1
|
||||||
// )
|
guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return }
|
||||||
// .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
|
||||||
// return APIService.Persist.persistStatus(
|
let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest
|
||||||
// managedObjectContext: self.backgroundManagedObjectContext,
|
lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, ids: accountIDs)
|
||||||
// domain: domain,
|
lookUpMastodonUserRequest.fetchLimit = accountIDs.count
|
||||||
// query: nil,
|
let lookUpMastodonusers = managedObjectContext.safeFetch(lookUpMastodonUserRequest)
|
||||||
// response: response,
|
|
||||||
// persistType: .user,
|
for user in lookUpMastodonusers {
|
||||||
// requestMastodonUserID: requestMastodonUserID,
|
guard let entity = response.value.first(where: { $0.id == user.id }) else { continue }
|
||||||
// log: OSLog.api
|
APIService.CoreData.update(user: user, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate)
|
||||||
// )
|
}
|
||||||
// .setFailureType(to: Error.self)
|
}
|
||||||
// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in
|
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> in
|
||||||
// switch result {
|
switch result {
|
||||||
// case .success:
|
case .success:
|
||||||
// return response
|
return response
|
||||||
// case .failure(let error):
|
case .failure(let error):
|
||||||
// throw error
|
throw error
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// .eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
// }
|
}
|
||||||
// .eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue