Merge branch 'release/0.6.0'
This commit is contained in:
commit
b190851056
|
@ -8,5 +8,5 @@
|
|||
import Foundation
|
||||
|
||||
public enum AppName {
|
||||
public static let groupID = "group.org.joinmastodon.mastodon-temp"
|
||||
public static let groupID = "group.org.joinmastodon.app"
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import Keys
|
|||
|
||||
public final class AppSecret {
|
||||
|
||||
public static let keychain = Keychain(service: "org.joinmastodon.Mastodon.keychain", accessGroup: AppName.groupID)
|
||||
public static let keychain = Keychain(service: "org.joinmastodon.app.keychain", accessGroup: AppName.groupID)
|
||||
|
||||
static let notificationPrivateKeyName = "notification-private-key-base64"
|
||||
static let notificationAuthName = "notification-auth-base64"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20E232" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20E241" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Application" representedClassName=".Application" syncable="YES">
|
||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
|
@ -102,6 +102,7 @@
|
|||
<attribute name="displayName" attributeType="String"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="emojisData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="fieldsData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="header" attributeType="String"/>
|
||||
|
@ -273,7 +274,7 @@
|
|||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
|
||||
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="689"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="704"/>
|
||||
<element name="Mention" positionX="0" positionY="0" width="128" height="149"/>
|
||||
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
|
||||
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
|
||||
|
|
|
@ -27,6 +27,7 @@ final public class MastodonUser: NSManagedObject {
|
|||
@NSManaged public private(set) var url: String?
|
||||
|
||||
@NSManaged public private(set) var emojisData: Data?
|
||||
@NSManaged public private(set) var fieldsData: Data?
|
||||
|
||||
@NSManaged public private(set) var statusesCount: NSNumber
|
||||
@NSManaged public private(set) var followingCount: NSNumber
|
||||
|
@ -92,6 +93,7 @@ extension MastodonUser {
|
|||
user.note = property.note
|
||||
user.url = property.url
|
||||
user.emojisData = property.emojisData
|
||||
user.fieldsData = property.fieldsData
|
||||
|
||||
user.statusesCount = NSNumber(value: property.statusesCount)
|
||||
user.followingCount = NSNumber(value: property.followingCount)
|
||||
|
@ -161,6 +163,11 @@ extension MastodonUser {
|
|||
self.emojisData = emojisData
|
||||
}
|
||||
}
|
||||
public func update(fieldsData: Data?) {
|
||||
if self.fieldsData != fieldsData {
|
||||
self.fieldsData = fieldsData
|
||||
}
|
||||
}
|
||||
public func update(statusesCount: Int) {
|
||||
if self.statusesCount.intValue != statusesCount {
|
||||
self.statusesCount = NSNumber(value: statusesCount)
|
||||
|
@ -281,6 +288,7 @@ extension MastodonUser {
|
|||
public let note: String?
|
||||
public let url: String?
|
||||
public let emojisData: Data?
|
||||
public let fieldsData: Data?
|
||||
public let statusesCount: Int
|
||||
public let followingCount: Int
|
||||
public let followersCount: Int
|
||||
|
@ -304,6 +312,7 @@ extension MastodonUser {
|
|||
note: String?,
|
||||
url: String?,
|
||||
emojisData: Data?,
|
||||
fieldsData: Data?,
|
||||
statusesCount: Int,
|
||||
followingCount: Int,
|
||||
followersCount: Int,
|
||||
|
@ -326,6 +335,7 @@ extension MastodonUser {
|
|||
self.note = note
|
||||
self.url = url
|
||||
self.emojisData = emojisData
|
||||
self.fieldsData = fieldsData
|
||||
self.statusesCount = statusesCount
|
||||
self.followingCount = followingCount
|
||||
self.followersCount = followersCount
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public protocol Managed: AnyObject, NSFetchRequestResult {
|
||||
public protocol Managed: NSFetchRequestResult {
|
||||
static var entityName: String { get }
|
||||
static var defaultSortDescriptors: [NSSortDescriptor] { get }
|
||||
}
|
||||
|
|
|
@ -21,7 +21,11 @@
|
|||
},
|
||||
"publish_post_failure": {
|
||||
"title": "Publish Failure",
|
||||
"message": "Failed to publish the post.\nPlease check your internet connection."
|
||||
"message": "Failed to publish the post.\nPlease check your internet connection.",
|
||||
"attchments_message": {
|
||||
"video_attach_with_photo": "Cannot attach a video to a status that already contains images.",
|
||||
"more_than_one_video": "Cannot attach more than one video."
|
||||
}
|
||||
},
|
||||
"sign_out": {
|
||||
"title": "Sign out",
|
||||
|
@ -39,6 +43,10 @@
|
|||
"delete_post": {
|
||||
"title": "Are you sure you want to delete this post?",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"clean_cache": {
|
||||
"title": "Clean Cache",
|
||||
"message": "Successfully clean %s cache."
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
|
@ -165,6 +173,9 @@
|
|||
"edit_info": "Edit info"
|
||||
},
|
||||
"timeline": {
|
||||
"timestamp": {
|
||||
"now": "Now"
|
||||
},
|
||||
"loader": {
|
||||
"load_missing_posts": "Load missing posts",
|
||||
"loading_missing_posts": "Loading missing posts...",
|
||||
|
@ -404,6 +415,13 @@
|
|||
"count_followers": "%ld followers"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"add_row": "Add Row",
|
||||
"placeholder": {
|
||||
"label": "Label",
|
||||
"content": "Content"
|
||||
}
|
||||
},
|
||||
"segmented_control": {
|
||||
"posts": "Posts",
|
||||
"replies": "Replies",
|
||||
|
|
|
@ -178,15 +178,12 @@
|
|||
5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; };
|
||||
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
|
||||
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
|
||||
7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; };
|
||||
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
|
||||
D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */; };
|
||||
B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */; };
|
||||
DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; };
|
||||
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; };
|
||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; };
|
||||
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; };
|
||||
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; };
|
||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
|
||||
DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; };
|
||||
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; };
|
||||
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; };
|
||||
DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */; };
|
||||
|
@ -375,6 +372,7 @@
|
|||
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; };
|
||||
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */; };
|
||||
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */; };
|
||||
DB97131F2666078B00BD1E90 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB97131E2666078B00BD1E90 /* Date.swift */; };
|
||||
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; };
|
||||
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; };
|
||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
|
||||
|
@ -401,6 +399,12 @@
|
|||
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; };
|
||||
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; };
|
||||
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */; };
|
||||
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */; };
|
||||
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */; };
|
||||
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */; };
|
||||
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94439265CC0FC00C537E1 /* Fields.swift */; };
|
||||
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */; };
|
||||
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.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 */; };
|
||||
|
@ -460,6 +464,9 @@
|
|||
DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; };
|
||||
DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; };
|
||||
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */; };
|
||||
DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */; };
|
||||
EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
@ -696,10 +703,10 @@
|
|||
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = "<group>"; };
|
||||
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = "<group>"; };
|
||||
5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = "<group>"; };
|
||||
|
@ -733,19 +740,19 @@
|
|||
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = "<group>"; };
|
||||
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = "<group>"; };
|
||||
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; };
|
||||
79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.release.xcconfig"; sourceTree = "<group>"; };
|
||||
B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = "<group>"; };
|
||||
BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = "<group>"; };
|
||||
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
|
||||
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = "<group>"; };
|
||||
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
|
||||
DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = "<group>"; };
|
||||
DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = "<group>"; };
|
||||
DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCollectionView.swift; sourceTree = "<group>"; };
|
||||
|
@ -936,6 +943,7 @@
|
|||
DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+Provider.swift"; sourceTree = "<group>"; };
|
||||
DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB97131E2666078B00BD1E90 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
|
||||
DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = "<group>"; };
|
||||
DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = "<group>"; };
|
||||
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = "<group>"; };
|
||||
|
@ -961,6 +969,12 @@
|
|||
DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = "<group>"; };
|
||||
DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = "<group>"; };
|
||||
DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = "<group>"; };
|
||||
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldSection.swift; sourceTree = "<group>"; };
|
||||
DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldItem.swift; sourceTree = "<group>"; };
|
||||
DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DBA94439265CC0FC00C537E1 /* Fields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fields.swift; sourceTree = "<group>"; };
|
||||
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Field.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>"; };
|
||||
|
@ -1019,7 +1033,11 @@
|
|||
DBF8AE15263293E400C9C23C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
DBF8AE17263293E400C9C23C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; };
|
||||
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewHeaderFooterView.swift; sourceTree = "<group>"; };
|
||||
DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldAddEntryCollectionViewCell.swift; 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>"; };
|
||||
ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
@ -1068,7 +1086,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DB6805102637D0F800430867 /* KeychainAccess in Frameworks */,
|
||||
D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */,
|
||||
EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -1096,7 +1114,7 @@
|
|||
DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */,
|
||||
DBF8AE862632992800C9C23C /* Base85 in Frameworks */,
|
||||
DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */,
|
||||
7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */,
|
||||
B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -1183,6 +1201,10 @@
|
|||
B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */,
|
||||
D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */,
|
||||
B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */,
|
||||
9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */,
|
||||
ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */,
|
||||
9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */,
|
||||
9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1402,6 +1424,7 @@
|
|||
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
|
||||
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
|
||||
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
|
||||
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */,
|
||||
);
|
||||
path = Section;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1463,6 +1486,7 @@
|
|||
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
|
||||
DB6D9F8326358EEC008423CD /* SettingsItem.swift */,
|
||||
DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */,
|
||||
DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */,
|
||||
);
|
||||
path = Item;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1526,8 +1550,8 @@
|
|||
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */,
|
||||
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */,
|
||||
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */,
|
||||
79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */,
|
||||
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */,
|
||||
F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */,
|
||||
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1590,7 +1614,6 @@
|
|||
DB68A03825E900CC00CFDF14 /* Share */,
|
||||
0FAA0FDD25E0B5700017CCDE /* Welcome */,
|
||||
0FAA102525E1125D0017CCDE /* PickServer */,
|
||||
DB0140A625C40C0900F9F3CF /* PinBasedAuthentication */,
|
||||
DBE0821A25CD382900FD6BBD /* Register */,
|
||||
DB72602125E36A2500235243 /* ServerRules */,
|
||||
2D364F7025E66D5B00204FDC /* ResendEmail */,
|
||||
|
@ -1599,16 +1622,6 @@
|
|||
path = Onboarding;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB0140A625C40C0900F9F3CF /* PinBasedAuthentication */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */,
|
||||
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */,
|
||||
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */,
|
||||
);
|
||||
path = PinBasedAuthentication;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB084B5125CBC56300F898ED /* CoreDataStack */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1619,6 +1632,7 @@
|
|||
DB6D9F4826353FD6008423CD /* Subscription.swift */,
|
||||
DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */,
|
||||
DBAFB7342645463500371D5F /* Emojis.swift */,
|
||||
DBA94439265CC0FC00C537E1 /* Fields.swift */,
|
||||
);
|
||||
path = CoreDataStack;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1892,6 +1906,7 @@
|
|||
children = (
|
||||
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */,
|
||||
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */,
|
||||
DB029E94266A20430062874E /* MastodonAuthenticationController.swift */,
|
||||
);
|
||||
path = Share;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1913,6 +1928,7 @@
|
|||
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */,
|
||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
|
||||
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */,
|
||||
DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */,
|
||||
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */,
|
||||
);
|
||||
path = MastodonSDK;
|
||||
|
@ -2139,6 +2155,7 @@
|
|||
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
||||
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
||||
DB6D1B23263684C600ACB481 /* UserDefaults.swift */,
|
||||
DB97131E2666078B00BD1E90 /* Date.swift */,
|
||||
);
|
||||
path = Extension;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2345,6 +2362,7 @@
|
|||
DBB525732612D5A5002F1F29 /* View */,
|
||||
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
|
||||
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */,
|
||||
DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */,
|
||||
);
|
||||
path = Header;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2357,6 +2375,9 @@
|
|||
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
|
||||
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
|
||||
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
|
||||
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */,
|
||||
DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */,
|
||||
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2756,7 +2777,7 @@
|
|||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Mastodon-NotificationService-checkManifestLockResult.txt",
|
||||
"$(DERIVED_FILE_DIR)/Pods-NotificationService-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
|
@ -2839,7 +2860,7 @@
|
|||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Mastodon-AppShared-checkManifestLockResult.txt",
|
||||
"$(DERIVED_FILE_DIR)/Pods-AppShared-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
|
@ -2921,6 +2942,7 @@
|
|||
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
|
||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
|
||||
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
|
||||
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */,
|
||||
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
|
||||
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
|
||||
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
|
||||
|
@ -2992,6 +3014,7 @@
|
|||
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
|
||||
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
|
||||
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
||||
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */,
|
||||
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
|
||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
||||
|
@ -3006,17 +3029,20 @@
|
|||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
||||
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
||||
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
|
||||
DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */,
|
||||
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */,
|
||||
DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */,
|
||||
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
|
||||
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
|
||||
DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */,
|
||||
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */,
|
||||
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
||||
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
|
||||
2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */,
|
||||
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
|
||||
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
|
||||
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
|
||||
DB97131F2666078B00BD1E90 /* Date.swift in Sources */,
|
||||
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
|
||||
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
|
||||
|
@ -3045,6 +3071,7 @@
|
|||
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
|
||||
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
|
||||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
|
||||
DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */,
|
||||
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */,
|
||||
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
|
||||
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */,
|
||||
|
@ -3059,6 +3086,7 @@
|
|||
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
|
||||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
|
||||
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */,
|
||||
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
|
||||
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
|
||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||
|
@ -3108,13 +3136,13 @@
|
|||
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
|
||||
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
|
||||
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
|
||||
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
|
||||
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
|
||||
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
|
||||
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
|
||||
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
|
||||
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
|
||||
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
|
||||
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */,
|
||||
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
|
||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
|
||||
|
@ -3152,6 +3180,7 @@
|
|||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||
DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */,
|
||||
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */,
|
||||
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
||||
|
@ -3177,7 +3206,6 @@
|
|||
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
|
||||
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
||||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
||||
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
||||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
||||
DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */,
|
||||
|
@ -3237,7 +3265,6 @@
|
|||
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
||||
DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */,
|
||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
||||
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
|
||||
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
||||
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
|
||||
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
|
||||
|
@ -3261,6 +3288,7 @@
|
|||
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
|
||||
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
|
||||
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
||||
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
|
||||
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */,
|
||||
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
|
||||
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
|
@ -3586,16 +3614,16 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon;
|
||||
MARKETING_VERSION = 0.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
@ -3613,16 +3641,16 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon;
|
||||
MARKETING_VERSION = 0.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
@ -3636,7 +3664,7 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = MastodonTests/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -3657,7 +3685,7 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = MastodonTests/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -3677,7 +3705,7 @@
|
|||
baseConfigurationReference = 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = MastodonUITests/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -3697,7 +3725,7 @@
|
|||
baseConfigurationReference = BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = MastodonUITests/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -3714,14 +3742,14 @@
|
|||
};
|
||||
DB6804892637CD4C00430867 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */;
|
||||
baseConfigurationReference = 9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
|
@ -3732,7 +3760,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
@ -3745,14 +3773,14 @@
|
|||
};
|
||||
DB68048A2637CD4C00430867 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */;
|
||||
baseConfigurationReference = ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
|
@ -3763,7 +3791,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
@ -3780,7 +3808,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
|
@ -3809,7 +3837,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
|
@ -3835,7 +3863,7 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = CoreDataStackTests/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -3855,7 +3883,7 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = CoreDataStackTests/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -3872,20 +3900,20 @@
|
|||
};
|
||||
DBF8AE1C263293E400C9C23C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */;
|
||||
baseConfigurationReference = 9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService;
|
||||
MARKETING_VERSION = 0.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
@ -3895,20 +3923,20 @@
|
|||
};
|
||||
DBF8AE1D263293E400C9C23C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */;
|
||||
baseConfigurationReference = 9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService;
|
||||
MARKETING_VERSION = 0.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<key>AppShared.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>18</integer>
|
||||
<integer>14</integer>
|
||||
</dict>
|
||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -27,12 +27,12 @@
|
|||
<key>Mastodon.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>13</integer>
|
||||
</dict>
|
||||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>14</integer>
|
||||
<integer>12</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -18,7 +18,7 @@ final class SafariActivity: UIActivity {
|
|||
}
|
||||
|
||||
override var activityType: UIActivity.ActivityType? {
|
||||
return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity")
|
||||
return UIActivity.ActivityType("org.joinmastodon.app.safari-activity")
|
||||
}
|
||||
|
||||
override var activityTitle: String? {
|
||||
|
|
|
@ -42,7 +42,6 @@ extension SceneCoordinator {
|
|||
// onboarding
|
||||
case welcome
|
||||
case mastodonPickServer(viewMode: MastodonPickServerViewModel)
|
||||
case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel)
|
||||
case mastodonRegister(viewModel: MastodonRegisterViewModel)
|
||||
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
|
||||
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
||||
|
@ -78,6 +77,7 @@ extension SceneCoordinator {
|
|||
case safari(url: URL)
|
||||
case alertController(alertController: UIAlertController)
|
||||
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
|
||||
|
||||
#if DEBUG
|
||||
case publicTimeline
|
||||
#endif
|
||||
|
@ -86,7 +86,6 @@ extension SceneCoordinator {
|
|||
switch self {
|
||||
case .welcome,
|
||||
.mastodonPickServer,
|
||||
.mastodonPinBasedAuthentication,
|
||||
.mastodonRegister,
|
||||
.mastodonServerRules,
|
||||
.mastodonConfirmEmail,
|
||||
|
@ -217,10 +216,6 @@ private extension SceneCoordinator {
|
|||
let _viewController = MastodonPickServerViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .mastodonPinBasedAuthentication(let viewModel):
|
||||
let _viewController = MastodonPinBasedAuthenticationViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .mastodonRegister(let viewModel):
|
||||
let _viewController = MastodonRegisterViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
|
@ -261,10 +256,6 @@ private extension SceneCoordinator {
|
|||
let _viewController = FavoriteViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .settings(let viewModel):
|
||||
let _viewController = SettingsViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .suggestionAccount(let viewModel):
|
||||
let _viewController = SuggestionAccountViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
//
|
||||
// ProfileFieldItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import MastodonSDK
|
||||
|
||||
enum ProfileFieldItem {
|
||||
case field(field: FieldValue, attribute: FieldItemAttribute)
|
||||
case addEntry(attribute: AddEntryItemAttribute)
|
||||
}
|
||||
|
||||
protocol ProfileFieldListSeparatorLineConfigurable: AnyObject {
|
||||
var isLast: Bool { get set }
|
||||
}
|
||||
|
||||
extension ProfileFieldItem {
|
||||
var listSeparatorLineConfigurable: ProfileFieldListSeparatorLineConfigurable? {
|
||||
switch self {
|
||||
case .field(_, let attribute):
|
||||
return attribute
|
||||
case .addEntry(let attribute):
|
||||
return attribute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileFieldItem {
|
||||
struct FieldValue: Equatable, Hashable {
|
||||
let id: UUID
|
||||
|
||||
var name: CurrentValueSubject<String, Never>
|
||||
var value: CurrentValueSubject<String, Never>
|
||||
|
||||
init(id: UUID = UUID(), name: String, value: String) {
|
||||
self.id = id
|
||||
self.name = CurrentValueSubject(name)
|
||||
self.value = CurrentValueSubject(value)
|
||||
}
|
||||
|
||||
func duplicate() -> FieldValue {
|
||||
FieldValue(name: name.value, value: value.value)
|
||||
}
|
||||
|
||||
static func == (lhs: ProfileFieldItem.FieldValue, rhs: ProfileFieldItem.FieldValue) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
&& lhs.name.value == rhs.name.value
|
||||
&& lhs.value.value == rhs.value.value
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileFieldItem {
|
||||
class FieldItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable {
|
||||
let emojiDict = CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>([:])
|
||||
|
||||
var isEditing = false
|
||||
var isLast = false
|
||||
|
||||
static func == (lhs: ProfileFieldItem.FieldItemAttribute, rhs: ProfileFieldItem.FieldItemAttribute) -> Bool {
|
||||
return lhs.isEditing == rhs.isEditing
|
||||
&& lhs.isLast == rhs.isLast
|
||||
}
|
||||
}
|
||||
|
||||
class AddEntryItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable {
|
||||
var isLast = false
|
||||
|
||||
static func == (lhs: ProfileFieldItem.AddEntryItemAttribute, rhs: ProfileFieldItem.AddEntryItemAttribute) -> Bool {
|
||||
return lhs.isLast == rhs.isLast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileFieldItem: Equatable {
|
||||
static func == (lhs: ProfileFieldItem, rhs: ProfileFieldItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.field(let fieldLeft, let attributeLeft), .field(let fieldRight, let attributeRight)):
|
||||
return fieldLeft.id == fieldRight.id
|
||||
&& attributeLeft == attributeRight
|
||||
case (.addEntry(let attributeLeft), .addEntry(let attributeRight)):
|
||||
return attributeLeft == attributeRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileFieldItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .field(let field, _):
|
||||
hasher.combine(field.id)
|
||||
case .addEntry:
|
||||
hasher.combine(String(describing: ProfileFieldItem.addEntry.self))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -55,7 +55,10 @@ extension ComposeStatusSection {
|
|||
switch item {
|
||||
case .replyTo(let replyToStatusObjectID):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell
|
||||
managedObjectContext.perform {
|
||||
// set empty text before retrieve real data to fix pseudo-text display issue
|
||||
cell.statusView.nameLabel.text = " "
|
||||
cell.statusView.usernameLabel.text = " "
|
||||
managedObjectContext.performAndWait {
|
||||
guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||
return
|
||||
}
|
||||
|
@ -73,7 +76,7 @@ extension ComposeStatusSection {
|
|||
//status.emoji
|
||||
cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
|
||||
// set date
|
||||
cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow
|
||||
|
||||
cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag)
|
||||
}
|
||||
|
@ -82,7 +85,7 @@ extension ComposeStatusSection {
|
|||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
|
||||
cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value
|
||||
cell.textEditorView.text = attribute.composeContent.value ?? ""
|
||||
managedObjectContext.perform {
|
||||
managedObjectContext.performAndWait {
|
||||
guard let replyToStatusObjectID = replyToStatusObjectID,
|
||||
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||
cell.statusView.headerContainerView.isHidden = true
|
||||
|
@ -98,21 +101,24 @@ extension ComposeStatusSection {
|
|||
cell.composeContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { text in
|
||||
.sink { [weak collectionView] text in
|
||||
guard let collectionView = collectionView else { return }
|
||||
// self size input cell
|
||||
// needs restore content offset to resolve issue #83
|
||||
let oldContentOffset = collectionView.contentOffset
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.layoutIfNeeded()
|
||||
collectionView.contentOffset = oldContentOffset
|
||||
|
||||
|
||||
// bind input data
|
||||
attribute.composeContent.value = text
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
attribute.isContentWarningComposing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { isContentWarningComposing in
|
||||
.sink { [weak cell, weak collectionView] isContentWarningComposing in
|
||||
guard let cell = cell else { return }
|
||||
guard let collectionView = collectionView else { return }
|
||||
// self size input cell
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
|
||||
|
@ -127,27 +133,28 @@ extension ComposeStatusSection {
|
|||
cell.contentWarningContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { text in
|
||||
.sink { [weak collectionView] text in
|
||||
guard let collectionView = collectionView else { return }
|
||||
// self size input cell
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
// bind input data
|
||||
attribute.contentWarningContent.value = text
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
|
||||
|
||||
return cell
|
||||
case .attachment(let attachmentService):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell
|
||||
cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value
|
||||
cell.delegate = composeStatusAttachmentTableViewCellDelegate
|
||||
attachmentService.imageData
|
||||
attachmentService.thumbnailImage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { imageData in
|
||||
.sink { [weak cell] thumbnailImage in
|
||||
guard let cell = cell else { return }
|
||||
let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1)
|
||||
guard let imageData = imageData,
|
||||
let image = UIImage(data: imageData) else {
|
||||
guard let image = thumbnailImage else {
|
||||
let placeholder = UIImage.placeholder(
|
||||
size: size,
|
||||
color: Asset.Colors.Background.systemGroupedBackground.color
|
||||
|
@ -168,17 +175,32 @@ extension ComposeStatusSection {
|
|||
attachmentService.error.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { uploadState, error in
|
||||
.sink { [weak cell, weak attachmentService] uploadState, error in
|
||||
guard let cell = cell else { return }
|
||||
guard let attachmentService = attachmentService else { return }
|
||||
cell.attachmentContainerView.emptyStateView.isHidden = error == nil
|
||||
cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil
|
||||
if let _ = error {
|
||||
if let error = error {
|
||||
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
||||
cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription
|
||||
} else {
|
||||
guard let uploadState = uploadState else { return }
|
||||
switch uploadState {
|
||||
case is MastodonAttachmentService.UploadState.Finish,
|
||||
is MastodonAttachmentService.UploadState.Fail:
|
||||
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
||||
cell.attachmentContainerView.emptyStateView.label.text = {
|
||||
if let file = attachmentService.file.value {
|
||||
switch file {
|
||||
case .jpeg, .png, .gif:
|
||||
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
||||
case .other:
|
||||
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
|
||||
}
|
||||
} else {
|
||||
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
||||
}
|
||||
}()
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
@ -206,7 +228,7 @@ extension ComposeStatusSection {
|
|||
.assign(to: \.value, on: attribute.option)
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.delegate = composeStatusPollOptionCollectionViewCellDelegate
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag)
|
||||
return cell
|
||||
case .pollOptionAppendEntry:
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell
|
||||
|
@ -217,7 +239,8 @@ extension ComposeStatusSection {
|
|||
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal)
|
||||
attribute.expiresOption
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { expiresOption in
|
||||
.sink { [weak cell] expiresOption in
|
||||
guard let cell = cell else { return }
|
||||
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
@ -272,7 +295,7 @@ protocol CustomEmojiReplaceableTextInput: AnyObject {
|
|||
var isFirstResponder: Bool { get }
|
||||
}
|
||||
|
||||
class CustomEmojiReplacableTextInputReference {
|
||||
class CustomEmojiReplaceableTextInputReference {
|
||||
weak var value: CustomEmojiReplaceableTextInput?
|
||||
|
||||
init(value: CustomEmojiReplaceableTextInput? = nil) {
|
||||
|
@ -297,7 +320,7 @@ extension ComposeStatusSection {
|
|||
|
||||
static func configureCustomEmojiPicker(
|
||||
viewModel: CustomEmojiPickerInputViewModel?,
|
||||
customEmojiReplacableTextInput: CustomEmojiReplaceableTextInput,
|
||||
customEmojiReplaceableTextInput: CustomEmojiReplaceableTextInput,
|
||||
disposeBag: inout Set<AnyCancellable>
|
||||
) {
|
||||
guard let viewModel = viewModel else { return }
|
||||
|
@ -305,9 +328,9 @@ extension ComposeStatusSection {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] isCustomEmojiComposing in
|
||||
guard let viewModel = viewModel else { return }
|
||||
customEmojiReplacableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
|
||||
customEmojiReplacableTextInput.reloadInputViews()
|
||||
viewModel.append(customEmojiReplacableTextInput: customEmojiReplacableTextInput)
|
||||
customEmojiReplaceableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
|
||||
customEmojiReplaceableTextInput.reloadInputViews()
|
||||
viewModel.append(customEmojiReplaceableTextInput: customEmojiReplaceableTextInput)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ extension NotificationSection {
|
|||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||
|
||||
let actionText = type.actionText
|
||||
let actionImageName = type.actionImageName
|
||||
|
@ -59,7 +59,7 @@ extension NotificationSection {
|
|||
)
|
||||
timestampUpdatePublisher
|
||||
.sink { _ in
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||
cell.actionLabel.text = actionText + " · " + timeText
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
@ -87,7 +87,7 @@ extension NotificationSection {
|
|||
cell.delegate = delegate
|
||||
timestampUpdatePublisher
|
||||
.sink { _ in
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||
cell.actionLabel.text = actionText + " · " + timeText
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
//
|
||||
// ProfileFieldSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-25.
|
||||
//
|
||||
|
||||
import os
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
enum ProfileFieldSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension ProfileFieldSection {
|
||||
static func collectionViewDiffableDataSource(
|
||||
for collectionView: UICollectionView,
|
||||
profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate,
|
||||
profileFieldAddEntryCollectionViewCellDelegate: ProfileFieldAddEntryCollectionViewCellDelegate
|
||||
) -> UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem> {
|
||||
let dataSource = UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>(collectionView: collectionView) {
|
||||
[
|
||||
weak profileFieldCollectionViewCellDelegate,
|
||||
weak profileFieldAddEntryCollectionViewCellDelegate
|
||||
] collectionView, indexPath, item in
|
||||
switch item {
|
||||
case .field(let field, let attribute):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self), for: indexPath) as! ProfileFieldCollectionViewCell
|
||||
|
||||
// set key
|
||||
cell.fieldView.titleActiveLabel.configure(field: field.name.value, emojiDict: attribute.emojiDict.value)
|
||||
cell.fieldView.titleTextField.text = field.name.value
|
||||
Publishers.CombineLatest(
|
||||
field.name.removeDuplicates(),
|
||||
attribute.emojiDict.removeDuplicates()
|
||||
)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak cell] name, emojiDict in
|
||||
guard let cell = cell else { return }
|
||||
cell.fieldView.titleActiveLabel.configure(field: name, emojiDict: emojiDict)
|
||||
cell.fieldView.titleTextField.text = name
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
|
||||
// set value
|
||||
cell.fieldView.valueActiveLabel.configure(field: field.value.value, emojiDict: attribute.emojiDict.value)
|
||||
cell.fieldView.valueTextField.text = field.value.value
|
||||
Publishers.CombineLatest(
|
||||
field.value.removeDuplicates(),
|
||||
attribute.emojiDict.removeDuplicates()
|
||||
)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak cell] value, emojiDict in
|
||||
guard let cell = cell else { return }
|
||||
cell.fieldView.valueActiveLabel.configure(field: value, emojiDict: emojiDict)
|
||||
cell.fieldView.valueTextField.text = value
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
// bind editing
|
||||
if attribute.isEditing {
|
||||
cell.fieldView.name
|
||||
.removeDuplicates()
|
||||
.receive(on: RunLoop.main)
|
||||
.assign(to: \.value, on: field.name)
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.fieldView.value
|
||||
.removeDuplicates()
|
||||
.receive(on: RunLoop.main)
|
||||
.assign(to: \.value, on: field.value)
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
// setup editing state
|
||||
cell.fieldView.titleTextField.isHidden = !attribute.isEditing
|
||||
cell.fieldView.valueTextField.isHidden = !attribute.isEditing
|
||||
cell.fieldView.titleActiveLabel.isHidden = attribute.isEditing
|
||||
cell.fieldView.valueActiveLabel.isHidden = attribute.isEditing
|
||||
|
||||
// set control hidden
|
||||
let isHidden = !attribute.isEditing
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update editing state: %s", ((#file as NSString).lastPathComponent), #line, #function, isHidden ? "true" : "false")
|
||||
cell.editButton.isHidden = isHidden
|
||||
cell.reorderBarImageView.isHidden = isHidden
|
||||
|
||||
// update separator line
|
||||
cell.bottomSeparatorLine.isHidden = attribute.isLast
|
||||
|
||||
cell.delegate = profileFieldCollectionViewCellDelegate
|
||||
|
||||
return cell
|
||||
|
||||
case .addEntry(let attribute):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self), for: indexPath) as! ProfileFieldAddEntryCollectionViewCell
|
||||
|
||||
cell.bottomSeparatorLine.isHidden = attribute.isLast
|
||||
cell.delegate = profileFieldAddEntryCollectionViewCellDelegate
|
||||
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
|
||||
switch kind {
|
||||
case UICollectionView.elementKindSectionHeader:
|
||||
let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView
|
||||
return reusableView
|
||||
case UICollectionView.elementKindSectionFooter:
|
||||
let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView
|
||||
return reusableView
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
|
@ -274,7 +274,8 @@ extension StatusSection {
|
|||
} else {
|
||||
meta.blurhashImagePublisher()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak cell] image in
|
||||
.sink { [weak blurhashImageCache] image in
|
||||
guard let blurhashImageCache = blurhashImageCache else { return }
|
||||
blurhashOverlayImageView.image = image
|
||||
image?.pngData().flatMap {
|
||||
blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey)
|
||||
|
@ -467,12 +468,12 @@ extension StatusSection {
|
|||
|
||||
// set date
|
||||
let createdAt = (status.reblog ?? status).createdAt
|
||||
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
|
||||
timestampUpdatePublisher
|
||||
.sink { [weak cell] _ in
|
||||
guard let cell = cell else { return }
|
||||
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow
|
||||
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.accessibilityLabel = createdAt.slowedTimeAgoSinceNow
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
|
|
|
@ -16,7 +16,8 @@ extension ActiveLabel {
|
|||
case `default`
|
||||
case statusHeader
|
||||
case statusName
|
||||
case profileField
|
||||
case profileFieldName
|
||||
case profileFieldValue
|
||||
}
|
||||
|
||||
convenience init(style: Style) {
|
||||
|
@ -46,8 +47,12 @@ extension ActiveLabel {
|
|||
font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
numberOfLines = 1
|
||||
case .profileField:
|
||||
font = .preferredFont(forTextStyle: .body)
|
||||
case .profileFieldName:
|
||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
numberOfLines = 1
|
||||
case .profileFieldValue:
|
||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
numberOfLines = 1
|
||||
}
|
||||
|
@ -78,10 +83,10 @@ extension ActiveLabel {
|
|||
|
||||
extension ActiveLabel {
|
||||
/// account field
|
||||
func configure(field: String) {
|
||||
func configure(field: String, emojiDict: MastodonStatusContent.EmojiDict) {
|
||||
activeEntities.removeAll()
|
||||
let parseResult = MastodonField.parse(field: field)
|
||||
text = parseResult.value
|
||||
let parseResult = MastodonField.parse(field: field, emojiDict: emojiDict)
|
||||
text = parseResult.trimmed
|
||||
activeEntities = parseResult.activeEntities
|
||||
accessibilityLabel = parseResult.value
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
//
|
||||
// Fields.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
protocol FieldContinaer {
|
||||
var fieldsData: Data? { get }
|
||||
}
|
||||
|
||||
extension FieldContinaer {
|
||||
|
||||
static func encode(fields: [Mastodon.Entity.Field]) -> Data? {
|
||||
return try? JSONEncoder().encode(fields)
|
||||
}
|
||||
|
||||
var fields: [Mastodon.Entity.Field]? {
|
||||
let decoder = JSONDecoder()
|
||||
return fieldsData.flatMap { try? decoder.decode([Mastodon.Entity.Field].self, from: $0) }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ extension MastodonUser.Property {
|
|||
headerStatic: entity.headerStatic,
|
||||
note: entity.note,
|
||||
url: entity.url,
|
||||
emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) },
|
||||
emojisData: entity.emojis.flatMap { MastodonUser.encode(emojis: $0) },
|
||||
fieldsData: entity.fields.flatMap { MastodonUser.encode(fields: $0) },
|
||||
statusesCount: entity.statusesCount,
|
||||
followingCount: entity.followingCount,
|
||||
followersCount: entity.followersCount,
|
||||
|
@ -101,3 +102,4 @@ extension MastodonUser {
|
|||
}
|
||||
|
||||
extension MastodonUser: EmojiContinaer { }
|
||||
extension MastodonUser: FieldContinaer { }
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// Date.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-1.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DateToolsSwift
|
||||
|
||||
extension Date {
|
||||
|
||||
var slowedTimeAgoSinceNow: String {
|
||||
return self.slowedTimeAgo(since: Date())
|
||||
|
||||
}
|
||||
|
||||
func slowedTimeAgo(since date: Date) -> String {
|
||||
let earlierDate = date < self ? date : self
|
||||
let latest = earlierDate == date ? self : date
|
||||
|
||||
if earlierDate.timeIntervalSince(latest) >= -60 {
|
||||
return L10n.Common.Controls.Timeline.Timestamp.now
|
||||
} else {
|
||||
return latest.shortTimeAgo(since: earlierDate)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
//
|
||||
// Mastodon+Entity+Field.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.Entity.Field: Equatable {
|
||||
public static func == (lhs: Mastodon.Entity.Field, rhs: Mastodon.Entity.Field) -> Bool {
|
||||
return lhs.name == rhs.name &&
|
||||
lhs.value == rhs.value &&
|
||||
lhs.verifiedAt == rhs.verifiedAt
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -21,6 +21,14 @@ internal enum L10n {
|
|||
return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1))
|
||||
}
|
||||
}
|
||||
internal enum CleanCache {
|
||||
/// Successfully clean %@ cache.
|
||||
internal static func message(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Common.Alerts.CleanCache.Message", String(describing: p1))
|
||||
}
|
||||
/// Clean Cache
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.CleanCache.Title")
|
||||
}
|
||||
internal enum Common {
|
||||
/// Please try again.
|
||||
internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain")
|
||||
|
@ -44,6 +52,12 @@ internal enum L10n {
|
|||
internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message")
|
||||
/// Publish Failure
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title")
|
||||
internal enum AttchmentsMessage {
|
||||
/// Cannot attach more than one video.
|
||||
internal static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo")
|
||||
/// Cannot attach a video to a status that already contains images.
|
||||
internal static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto")
|
||||
}
|
||||
}
|
||||
internal enum SavePhotoFailure {
|
||||
/// Please enable photo libaray access permission to save photo.
|
||||
|
@ -366,6 +380,10 @@ internal enum L10n {
|
|||
/// Show more replies
|
||||
internal static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies")
|
||||
}
|
||||
internal enum Timestamp {
|
||||
/// Now
|
||||
internal static let now = L10n.tr("Localizable", "Common.Controls.Timeline.Timestamp.Now")
|
||||
}
|
||||
}
|
||||
}
|
||||
internal enum Countable {
|
||||
|
@ -626,6 +644,16 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
}
|
||||
internal enum Fields {
|
||||
/// Add Row
|
||||
internal static let addRow = L10n.tr("Localizable", "Scene.Profile.Fields.AddRow")
|
||||
internal enum Placeholder {
|
||||
/// Content
|
||||
internal static let content = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Content")
|
||||
/// Label
|
||||
internal static let label = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Label")
|
||||
}
|
||||
}
|
||||
internal enum RelationshipActionAlert {
|
||||
internal enum ConfirmUnblockUsre {
|
||||
/// Confirm unblock %@
|
||||
|
|
|
@ -10,12 +10,25 @@ import ActiveLabel
|
|||
|
||||
enum MastodonField {
|
||||
|
||||
static func parse(field string: String) -> ParseResult {
|
||||
static func parse(field string: String, emojiDict: MastodonStatusContent.EmojiDict) -> ParseResult {
|
||||
// use content parser get emoji entities
|
||||
let value = string
|
||||
|
||||
var string = string
|
||||
var entities: [ActiveEntity] = []
|
||||
|
||||
do {
|
||||
let contentParseresult = try MastodonStatusContent.parse(content: string, emojiDict: emojiDict)
|
||||
string = contentParseresult.trimmed
|
||||
entities.append(contentsOf: contentParseresult.activeEntities)
|
||||
} catch {
|
||||
// assertionFailure(error.localizedDescription)
|
||||
}
|
||||
|
||||
let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)")
|
||||
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
|
||||
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
|
||||
|
||||
var entities: [ActiveEntity] = []
|
||||
|
||||
for match in mentionMatches {
|
||||
guard let text = string.substring(with: match, at: 0) else { continue }
|
||||
|
@ -35,7 +48,7 @@ enum MastodonField {
|
|||
entities.append(entity)
|
||||
}
|
||||
|
||||
return ParseResult(value: string, activeEntities: entities)
|
||||
return ParseResult(value: value, trimmed: string, activeEntities: entities)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -43,6 +56,7 @@ enum MastodonField {
|
|||
extension MastodonField {
|
||||
struct ParseResult {
|
||||
let value: String
|
||||
let trimmed: String
|
||||
let activeEntities: [ActiveEntity]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ enum MastodonStatusContent {
|
|||
let pattern = ":\(shortcode):"
|
||||
content = content.replacingOccurrences(of: pattern, with: emojiNode)
|
||||
}
|
||||
return content
|
||||
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}()
|
||||
let rootNode = try Node.parse(document: document)
|
||||
let text = String(rootNode.text)
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.org.joinmastodon.mastodon-temp</string>
|
||||
<string>group.org.joinmastodon.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -129,7 +129,17 @@ extension StatusProviderFacade {
|
|||
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))
|
||||
if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain,
|
||||
url.pathComponents.count >= 4,
|
||||
url.pathComponents[0] == "/",
|
||||
url.pathComponents[1] == "web",
|
||||
url.pathComponents[2] == "statuses" {
|
||||
let statusID = url.pathComponents[3]
|
||||
let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID)
|
||||
provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
|
||||
} else {
|
||||
provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
|
||||
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
|
||||
"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
|
||||
"Common.Alerts.CleanCache.Title" = "Clean Cache";
|
||||
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
|
||||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||
"Common.Alerts.DeletePost.Delete" = "Delete";
|
||||
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
|
||||
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
|
@ -128,6 +132,7 @@ Your account looks like this to them.";
|
|||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||
"Common.Controls.Timeline.Timestamp.Now" = "Now";
|
||||
"Common.Countable.Photo.Multiple" = "photos";
|
||||
"Common.Countable.Photo.Single" = "photo";
|
||||
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
|
||||
|
@ -212,6 +217,9 @@ tap the link to confirm your account.";
|
|||
"Scene.Profile.Dashboard.Followers" = "followers";
|
||||
"Scene.Profile.Dashboard.Following" = "following";
|
||||
"Scene.Profile.Dashboard.Posts" = "posts";
|
||||
"Scene.Profile.Fields.AddRow" = "Add Row";
|
||||
"Scene.Profile.Fields.Placeholder.Content" = "Content";
|
||||
"Scene.Profile.Fields.Placeholder.Label" = "Label";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
|
||||
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
|
||||
"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
|
||||
"Common.Alerts.CleanCache.Title" = "Clean Cache";
|
||||
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
|
||||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||
"Common.Alerts.DeletePost.Delete" = "Delete";
|
||||
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
|
||||
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
|
@ -128,6 +132,7 @@ Your account looks like this to them.";
|
|||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||
"Common.Controls.Timeline.Timestamp.Now" = "Now";
|
||||
"Common.Countable.Photo.Multiple" = "photos";
|
||||
"Common.Countable.Photo.Single" = "photo";
|
||||
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
|
||||
|
@ -212,6 +217,9 @@ tap the link to confirm your account.";
|
|||
"Scene.Profile.Dashboard.Followers" = "followers";
|
||||
"Scene.Profile.Dashboard.Following" = "following";
|
||||
"Scene.Profile.Dashboard.Posts" = "posts";
|
||||
"Scene.Profile.Fields.AddRow" = "Add Row";
|
||||
"Scene.Profile.Fields.Placeholder.Content" = "Content";
|
||||
"Scene.Profile.Fields.Placeholder.Label" = "Label";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
|
||||
|
|
|
@ -57,6 +57,10 @@ final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell {
|
|||
_init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeStatusAttachmentCollectionViewCell {
|
||||
|
|
|
@ -61,7 +61,8 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
var systemKeyboardHeight: CGFloat = .zero {
|
||||
didSet {
|
||||
// note: some system AutoLayout warning here
|
||||
customEmojiPickerInputView.frame.size.height = systemKeyboardHeight != .zero ? systemKeyboardHeight : 300
|
||||
let height = max(300, systemKeyboardHeight)
|
||||
customEmojiPickerInputView.frame.size.height = height
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,12 +76,15 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
|
||||
let composeToolbarBackgroundView = UIView()
|
||||
|
||||
private(set) lazy var imagePicker: PHPickerViewController = {
|
||||
static func createPhotoLibraryPickerConfiguration(selectionLimit: Int = 4) -> PHPickerConfiguration {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 4
|
||||
|
||||
let imagePicker = PHPickerViewController(configuration: configuration)
|
||||
configuration.filter = .any(of: [.images, .videos])
|
||||
configuration.selectionLimit = selectionLimit
|
||||
return configuration
|
||||
}
|
||||
|
||||
private(set) lazy var photoLibraryPicker: PHPickerViewController = {
|
||||
let imagePicker = PHPickerViewController(configuration: ComposeViewController.createPhotoLibraryPickerConfiguration())
|
||||
imagePicker.delegate = self
|
||||
return imagePicker
|
||||
}()
|
||||
|
@ -92,7 +96,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
}()
|
||||
|
||||
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
|
||||
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
|
||||
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image, .movie])
|
||||
documentPickerController.delegate = self
|
||||
return documentPickerController
|
||||
}()
|
||||
|
@ -567,12 +571,9 @@ extension ComposeViewController {
|
|||
}
|
||||
|
||||
private func resetImagePicker() {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = .images
|
||||
let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count)
|
||||
configuration.selectionLimit = selectionLimit
|
||||
|
||||
imagePicker = createImagePicker(configuration: configuration)
|
||||
let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit)
|
||||
photoLibraryPicker = createImagePicker(configuration: configuration)
|
||||
}
|
||||
|
||||
private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController {
|
||||
|
@ -610,6 +611,16 @@ extension ComposeViewController {
|
|||
|
||||
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
do {
|
||||
try viewModel.checkAttachmentPrecondition()
|
||||
} catch {
|
||||
let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
|
||||
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||
alertController.addAction(okAction)
|
||||
coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else {
|
||||
// TODO: handle error
|
||||
return
|
||||
|
@ -720,7 +731,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
|
|||
guard let emoji = customEmojiViewModel.emoji(shortcode: name) else { continue }
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name)
|
||||
|
||||
// set emoji token invisiable (without upper bounce space)
|
||||
// set emoji token invisible (without upper bounce space)
|
||||
var attributes = [NSAttributedString.Key: Any]()
|
||||
attributes[.font] = UIFont.systemFont(ofSize: 0.01)
|
||||
attributedString.addAttributes(attributes, range: match.range)
|
||||
|
@ -802,15 +813,15 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
|
|||
extension ComposeViewController: TextEditorViewChangeObserver {
|
||||
|
||||
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
|
||||
guard var autoCompeletion = ComposeViewController.scanAutoCompleteInfo(textEditorView: textEditorView) else {
|
||||
guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textEditorView: textEditorView) else {
|
||||
viewModel.autoCompleteInfo.value = nil
|
||||
return
|
||||
}
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompeletion.toHighlightEndString), String(autoCompeletion.toCursorString))
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString))
|
||||
|
||||
// get layout text bounding rect
|
||||
var glyphRange = NSRange()
|
||||
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
|
||||
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
|
||||
let textContainer = textEditorView.layoutManager.textContainers[0]
|
||||
let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
|
||||
|
@ -828,13 +839,13 @@ extension ComposeViewController: TextEditorViewChangeObserver {
|
|||
viewModel.autoCompleteRetryLayoutTimes.value = 0
|
||||
|
||||
// get symbol bounding rect
|
||||
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.symbolRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
|
||||
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
|
||||
let symbolBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
|
||||
// set bounding rect and trigger layout
|
||||
autoCompeletion.textBoundingRect = textBoundingRect
|
||||
autoCompeletion.symbolBoundingRect = symbolBoundingRect
|
||||
viewModel.autoCompleteInfo.value = autoCompeletion
|
||||
autoCompletion.textBoundingRect = textBoundingRect
|
||||
autoCompletion.symbolBoundingRect = symbolBoundingRect
|
||||
viewModel.autoCompleteInfo.value = autoCompletion
|
||||
}
|
||||
|
||||
struct AutoCompleteInfo {
|
||||
|
@ -854,12 +865,14 @@ extension ComposeViewController: TextEditorViewChangeObserver {
|
|||
|
||||
private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? {
|
||||
let text = textEditorView.text
|
||||
let cursorLocation = textEditorView.selectedRange.location
|
||||
let cursorIndex = text.index(text.startIndex, offsetBy: cursorLocation)
|
||||
guard cursorLocation > 0, !text.isEmpty else { return nil }
|
||||
|
||||
let _highlighStartIndex: String.Index? = {
|
||||
var index = text.index(text.startIndex, offsetBy: cursorLocation - 1)
|
||||
|
||||
guard textEditorView.selectedRange.location > 0, !text.isEmpty,
|
||||
let selectedRange = Range(textEditorView.selectedRange, in: text) else {
|
||||
return nil
|
||||
}
|
||||
let cursorIndex = selectedRange.upperBound
|
||||
let _highlightStartIndex: String.Index? = {
|
||||
var index = text.index(before: cursorIndex)
|
||||
while index > text.startIndex {
|
||||
let char = text[index]
|
||||
if char == "@" || char == "#" || char == ":" {
|
||||
|
@ -876,18 +889,18 @@ extension ComposeViewController: TextEditorViewChangeObserver {
|
|||
}
|
||||
}()
|
||||
|
||||
guard let highlighStartIndex = _highlighStartIndex else { return nil }
|
||||
let scanRange = NSRange(highlighStartIndex..<text.endIndex, in: text)
|
||||
guard let highlightStartIndex = _highlightStartIndex else { return nil }
|
||||
let scanRange = NSRange(highlightStartIndex..<text.endIndex, in: text)
|
||||
|
||||
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
|
||||
let matchRange = match.range(at: 0)
|
||||
let matchStartIndex = text.index(text.startIndex, offsetBy: matchRange.location)
|
||||
let matchEndIndex = text.index(matchStartIndex, offsetBy: matchRange.length)
|
||||
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
|
||||
let matchStartIndex = matchRange.lowerBound
|
||||
let matchEndIndex = matchRange.upperBound
|
||||
|
||||
guard matchStartIndex == highlighStartIndex, matchEndIndex >= cursorIndex else { return nil }
|
||||
let symbolRange = highlighStartIndex..<text.index(after: highlighStartIndex)
|
||||
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
|
||||
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
|
||||
let symbolString = text[symbolRange]
|
||||
let toCursorRange = highlighStartIndex..<cursorIndex
|
||||
let toCursorRange = highlightStartIndex..<cursorIndex
|
||||
let toCursorString = text[toCursorRange]
|
||||
let toHighlightEndRange = matchStartIndex..<matchEndIndex
|
||||
let toHighlightEndString = text[toHighlightEndRange]
|
||||
|
@ -913,7 +926,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
|
|||
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) {
|
||||
switch type {
|
||||
case .photoLibrary:
|
||||
present(imagePicker, animated: true, completion: nil)
|
||||
present(photoLibraryPicker, animated: true, completion: nil)
|
||||
case .camera:
|
||||
present(imagePickerController, animated: true, completion: nil)
|
||||
case .browse:
|
||||
|
@ -1005,7 +1018,7 @@ extension ComposeViewController: UICollectionViewDelegate {
|
|||
let emoji = attribute.emoji
|
||||
let textEditorView = self.textEditorView()
|
||||
|
||||
// retrive active text input and insert emoji
|
||||
// retrieve active text input and insert emoji
|
||||
// the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue
|
||||
let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ")
|
||||
|
||||
|
@ -1018,6 +1031,9 @@ extension ComposeViewController: UICollectionViewDelegate {
|
|||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
|
||||
// make click sound
|
||||
UIDevice.current.playInputClick()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -1058,7 +1074,7 @@ extension ComposeViewController: PHPickerViewControllerDelegate {
|
|||
let service = MastodonAttachmentService(
|
||||
context: context,
|
||||
pickerResult: result,
|
||||
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||
)
|
||||
return service
|
||||
}
|
||||
|
@ -1077,7 +1093,7 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC
|
|||
let attachmentService = MastodonAttachmentService(
|
||||
context: context,
|
||||
image: image,
|
||||
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||
)
|
||||
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
|
||||
}
|
||||
|
@ -1092,20 +1108,13 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC
|
|||
extension ComposeViewController: UIDocumentPickerDelegate {
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
do {
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
let imageData = try Data(contentsOf: url)
|
||||
let attachmentService = MastodonAttachmentService(
|
||||
context: context,
|
||||
imageData: imageData,
|
||||
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||
)
|
||||
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
|
||||
} catch {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
}
|
||||
|
||||
let attachmentService = MastodonAttachmentService(
|
||||
context: context,
|
||||
documentURL: url,
|
||||
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||
)
|
||||
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1120,8 +1129,12 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega
|
|||
|
||||
var attachmentServices = viewModel.attachmentServices.value
|
||||
guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
|
||||
let removedItem = attachmentServices[index]
|
||||
attachmentServices.remove(at: index)
|
||||
viewModel.attachmentServices.value = attachmentServices
|
||||
|
||||
// cancel task
|
||||
removedItem.disposeBag.removeAll()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1365,7 +1378,7 @@ extension ComposeViewController {
|
|||
case .mediaBrowse:
|
||||
present(documentPickerController, animated: true, completion: nil)
|
||||
case .mediaPhotoLibrary:
|
||||
present(imagePicker, animated: true, completion: nil)
|
||||
present(photoLibraryPicker, animated: true, completion: nil)
|
||||
case .mediaCamera:
|
||||
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
|
||||
return
|
||||
|
|
|
@ -291,6 +291,8 @@ final class ComposeViewModel {
|
|||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] attachmentServices, isPollComposing, pollAttributes in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: trigger attachments upload…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
|
@ -405,6 +407,59 @@ extension ComposeViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
extension ComposeViewModel {
|
||||
|
||||
enum AttachmentPrecondition: Error, LocalizedError {
|
||||
case videoAttachWithPhoto
|
||||
case moreThanOneVideo
|
||||
|
||||
var errorDescription: String? {
|
||||
return L10n.Common.Alerts.PublishPostFailure.title
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .videoAttachWithPhoto:
|
||||
return L10n.Common.Alerts.PublishPostFailure.AttchmentsMessage.videoAttachWithPhoto
|
||||
case .moreThanOneVideo:
|
||||
return L10n.Common.Alerts.PublishPostFailure.AttchmentsMessage.moreThanOneVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check exclusive limit:
|
||||
// - up to 1 video
|
||||
// - up to 4 photos
|
||||
func checkAttachmentPrecondition() throws {
|
||||
let attachmentServices = self.attachmentServices.value
|
||||
guard !attachmentServices.isEmpty else { return }
|
||||
var photoAttachmentServices: [MastodonAttachmentService] = []
|
||||
var videoAttachmentServices: [MastodonAttachmentService] = []
|
||||
attachmentServices.forEach { service in
|
||||
guard let file = service.file.value else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
switch file {
|
||||
case .jpeg, .png, .gif:
|
||||
photoAttachmentServices.append(service)
|
||||
case .other:
|
||||
videoAttachmentServices.append(service)
|
||||
}
|
||||
}
|
||||
|
||||
if !videoAttachmentServices.isEmpty {
|
||||
guard videoAttachmentServices.count == 1 else {
|
||||
throw AttachmentPrecondition.moreThanOneVideo
|
||||
}
|
||||
guard photoAttachmentServices.isEmpty else {
|
||||
throw AttachmentPrecondition.videoAttachWithPhoto
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - MastodonAttachmentServiceDelegate
|
||||
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
|
||||
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
|
||||
|
|
|
@ -29,6 +29,8 @@ extension AttachmentContainerView {
|
|||
label.textAlignment = .center
|
||||
label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
||||
label.numberOfLines = 2
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
label.minimumScaleFactor = 0.3
|
||||
return label
|
||||
}()
|
||||
|
||||
|
|
|
@ -218,6 +218,12 @@ extension ComposeToolbarView {
|
|||
}
|
||||
|
||||
private func updateToolbarButtonUserInterfaceStyle() {
|
||||
// reset emoji
|
||||
let emojiButtonImage = Asset.Human.faceSmilingAdaptive.image
|
||||
.af.imageScaled(to: CGSize(width: 20, height: 20))
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
emojiButton.setImage(emojiButtonImage, for: .normal)
|
||||
|
||||
switch traitCollection.userInterfaceStyle {
|
||||
case .light:
|
||||
mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
|
||||
|
|
|
@ -84,3 +84,9 @@ extension CustomEmojiPickerInputView {
|
|||
return layout
|
||||
}
|
||||
}
|
||||
|
||||
extension CustomEmojiPickerInputView: UIInputViewAudioFeedback {
|
||||
var enableInputClicksWhenVisible: Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ final class CustomEmojiPickerInputViewModel {
|
|||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
private var customEmojiReplacableTextInputReferences: [CustomEmojiReplacableTextInputReference] = []
|
||||
private var customEmojiReplaceableTextInputReferences: [CustomEmojiReplaceableTextInputReference] = []
|
||||
|
||||
// input
|
||||
weak var customEmojiPickerInputView: CustomEmojiPickerInputView?
|
||||
|
@ -25,27 +25,27 @@ final class CustomEmojiPickerInputViewModel {
|
|||
extension CustomEmojiPickerInputViewModel {
|
||||
|
||||
private func removeEmptyReferences() {
|
||||
customEmojiReplacableTextInputReferences.removeAll(where: { element in
|
||||
customEmojiReplaceableTextInputReferences.removeAll(where: { element in
|
||||
element.value == nil
|
||||
})
|
||||
}
|
||||
|
||||
func append(customEmojiReplacableTextInput textInput: CustomEmojiReplaceableTextInput) {
|
||||
func append(customEmojiReplaceableTextInput textInput: CustomEmojiReplaceableTextInput) {
|
||||
removeEmptyReferences()
|
||||
|
||||
let isContains = customEmojiReplacableTextInputReferences.contains(where: { element in
|
||||
let isContains = customEmojiReplaceableTextInputReferences.contains(where: { element in
|
||||
element.value === textInput
|
||||
})
|
||||
guard !isContains else {
|
||||
return
|
||||
}
|
||||
customEmojiReplacableTextInputReferences.append(CustomEmojiReplacableTextInputReference(value: textInput))
|
||||
customEmojiReplaceableTextInputReferences.append(CustomEmojiReplaceableTextInputReference(value: textInput))
|
||||
}
|
||||
|
||||
func insertText(_ text: String) -> CustomEmojiReplacableTextInputReference? {
|
||||
func insertText(_ text: String) -> CustomEmojiReplaceableTextInputReference? {
|
||||
removeEmptyReferences()
|
||||
|
||||
for reference in customEmojiReplacableTextInputReferences {
|
||||
for reference in customEmojiReplaceableTextInputReferences {
|
||||
guard reference.value?.isFirstResponder == true else { continue }
|
||||
reference.value?.insertText(text)
|
||||
return reference
|
||||
|
|
|
@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadMiddleState {
|
|||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
|
||||
_ = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
|
||||
status.id
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ import CoreData
|
|||
import CoreDataStack
|
||||
|
||||
#if DEBUG
|
||||
import FLEX
|
||||
|
||||
extension HomeTimelineViewController {
|
||||
var debugMenu: UIMenu {
|
||||
let menu = UIMenu(
|
||||
|
@ -19,6 +21,10 @@ extension HomeTimelineViewController {
|
|||
identifier: nil,
|
||||
options: .displayInline,
|
||||
children: [
|
||||
UIAction(title: "Show FLEX", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showFLEXAction(action)
|
||||
}),
|
||||
moveMenu,
|
||||
dropMenu,
|
||||
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
|
||||
|
@ -115,6 +121,10 @@ extension HomeTimelineViewController {
|
|||
|
||||
extension HomeTimelineViewController {
|
||||
|
||||
@objc private func showFLEXAction(_ sender: UIAction) {
|
||||
FLEXManager.shared.showExplorer()
|
||||
}
|
||||
|
||||
@objc private func moveToTopGapAction(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
|
|
|
@ -9,6 +9,7 @@ import os.log
|
|||
import UIKit
|
||||
import Combine
|
||||
import GameController
|
||||
import AuthenticationServices
|
||||
|
||||
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -19,6 +20,11 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
|||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var viewModel: MastodonPickServerViewModel!
|
||||
private(set) lazy var authenticationViewModel = AuthenticationViewModel(
|
||||
context: context,
|
||||
coordinator: coordinator,
|
||||
isAuthenticationExist: false
|
||||
)
|
||||
|
||||
private var expandServerDomainSet = Set<String>()
|
||||
|
||||
|
@ -50,6 +56,8 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
|||
}()
|
||||
var nextStepButtonBottomLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
var mastodonAuthenticationController: MastodonAuthenticationController?
|
||||
|
||||
deinit {
|
||||
tableViewObservation = nil
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
@ -182,23 +190,26 @@ extension MastodonPickServerViewController {
|
|||
.assign(to: \.isEnabled, on: nextStepButton)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.error
|
||||
.compactMap { $0 }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert)
|
||||
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||
alertController.addAction(okAction)
|
||||
self.coordinator.present(
|
||||
scene: .alertController(alertController: alertController),
|
||||
from: nil,
|
||||
transition: .alertController(animated: true, completion: nil)
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
Publishers.Merge(
|
||||
viewModel.error,
|
||||
authenticationViewModel.error
|
||||
)
|
||||
.compactMap { $0 }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert)
|
||||
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||
alertController.addAction(okAction)
|
||||
self.coordinator.present(
|
||||
scene: .alertController(alertController: alertController),
|
||||
from: nil,
|
||||
transition: .alertController(animated: true, completion: nil)
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel
|
||||
authenticationViewModel
|
||||
.authenticated
|
||||
.flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in
|
||||
guard let self = self else { return Just(.success(false)).eraseToAnyPublisher() }
|
||||
|
@ -217,7 +228,7 @@ extension MastodonPickServerViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.isAuthenticating
|
||||
authenticationViewModel.isAuthenticating
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isAuthenticating in
|
||||
guard let self = self else { return }
|
||||
|
@ -273,11 +284,15 @@ extension MastodonPickServerViewController {
|
|||
|
||||
private func doSignIn() {
|
||||
guard let server = viewModel.selectedServer.value else { return }
|
||||
viewModel.isAuthenticating.send(true)
|
||||
authenticationViewModel.isAuthenticating.send(true)
|
||||
context.apiService.createApplication(domain: server.domain)
|
||||
.tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in
|
||||
.tryMap { response -> AuthenticationViewModel.AuthenticateInfo in
|
||||
let application = response.value
|
||||
guard let info = MastodonPickServerViewModel.AuthenticateInfo(domain: server.domain, application: application) else {
|
||||
guard let info = AuthenticationViewModel.AuthenticateInfo(
|
||||
domain: server.domain,
|
||||
application: application,
|
||||
redirectURI: response.value.redirectURI ?? MastodonAuthenticationController.callbackURL
|
||||
) else {
|
||||
throw APIService.APIError.explicit(.badResponse)
|
||||
}
|
||||
return info
|
||||
|
@ -285,7 +300,7 @@ extension MastodonPickServerViewController {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
self.viewModel.isAuthenticating.send(false)
|
||||
self.authenticationViewModel.isAuthenticating.send(false)
|
||||
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
|
@ -296,15 +311,19 @@ extension MastodonPickServerViewController {
|
|||
}
|
||||
} receiveValue: { [weak self] info in
|
||||
guard let self = self else { return }
|
||||
let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.authorizeURL)
|
||||
self.viewModel.authenticate(
|
||||
info: info,
|
||||
pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher
|
||||
let authenticationController = MastodonAuthenticationController(
|
||||
context: self.context,
|
||||
authenticateURL: info.authorizeURL
|
||||
)
|
||||
self.viewModel.mastodonPinBasedAuthenticationViewController = self.coordinator.present(
|
||||
scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel),
|
||||
from: nil,
|
||||
transition: .modal(animated: true, completion: nil)
|
||||
|
||||
self.mastodonAuthenticationController = authenticationController
|
||||
authenticationController.authenticationSession?.prefersEphemeralWebBrowserSession = true
|
||||
authenticationController.authenticationSession?.presentationContextProvider = self
|
||||
authenticationController.authenticationSession?.start()
|
||||
|
||||
self.authenticationViewModel.authenticate(
|
||||
info: info,
|
||||
pinCodePublisher: authenticationController.pinCodePublisher
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
@ -313,7 +332,7 @@ extension MastodonPickServerViewController {
|
|||
private func doSignUp() {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let server = viewModel.selectedServer.value else { return }
|
||||
viewModel.isAuthenticating.send(true)
|
||||
authenticationViewModel.isAuthenticating.send(true)
|
||||
|
||||
context.apiService.instance(domain: server.domain)
|
||||
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseFirst, Error>? in
|
||||
|
@ -328,7 +347,10 @@ extension MastodonPickServerViewController {
|
|||
.switchToLatest()
|
||||
.tryMap { response -> MastodonPickServerViewModel.SignUpResponseSecond in
|
||||
let application = response.application.value
|
||||
guard let authenticateInfo = AuthenticationViewModel.AuthenticateInfo(domain: server.domain, application: application) else {
|
||||
guard let authenticateInfo = AuthenticationViewModel.AuthenticateInfo(
|
||||
domain: server.domain,
|
||||
application: application
|
||||
) else {
|
||||
throw APIService.APIError.explicit(.badResponse)
|
||||
}
|
||||
return MastodonPickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo)
|
||||
|
@ -340,7 +362,8 @@ extension MastodonPickServerViewController {
|
|||
return self.context.apiService.applicationAccessToken(
|
||||
domain: server.domain,
|
||||
clientID: authenticateInfo.clientID,
|
||||
clientSecret: authenticateInfo.clientSecret
|
||||
clientSecret: authenticateInfo.clientSecret,
|
||||
redirectURI: authenticateInfo.redirectURI
|
||||
)
|
||||
.map { MastodonPickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
|
||||
.eraseToAnyPublisher()
|
||||
|
@ -349,7 +372,7 @@ extension MastodonPickServerViewController {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
self.viewModel.isAuthenticating.send(false)
|
||||
self.authenticationViewModel.isAuthenticating.send(false)
|
||||
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
|
@ -519,3 +542,10 @@ extension MastodonPickServerViewController: PickServerCellDelegate {
|
|||
|
||||
// MARK: - OnboardingViewControllerAppearance
|
||||
extension MastodonPickServerViewController: OnboardingViewControllerAppearance { }
|
||||
|
||||
// MARK: - ASWebAuthenticationPresentationContextProviding
|
||||
extension MastodonPickServerViewController: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
return view.window!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,15 +58,11 @@ class MastodonPickServerViewModel: NSObject {
|
|||
let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
|
||||
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
|
||||
let error = PassthroughSubject<Error, Never>()
|
||||
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
|
||||
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
|
||||
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||
|
||||
let isLoadingIndexedServers = CurrentValueSubject<Bool, Never>(false)
|
||||
let emptyStateViewState = CurrentValueSubject<EmptyStateViewState, Never>(.none)
|
||||
|
||||
var mastodonPinBasedAuthenticationViewController: UIViewController?
|
||||
|
||||
|
||||
init(context: AppContext, mode: PickServerMode) {
|
||||
self.context = context
|
||||
self.mode = mode
|
||||
|
@ -233,156 +229,6 @@ extension MastodonPickServerViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SignIn methods & structs
|
||||
extension MastodonPickServerViewModel {
|
||||
enum AuthenticationError: Error, LocalizedError {
|
||||
case badCredentials
|
||||
case registrationClosed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badCredentials: return "Bad Credentials"
|
||||
case .registrationClosed: return "Registration Closed"
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .badCredentials: return "Credentials invalid."
|
||||
case .registrationClosed: return "Server disallow registration."
|
||||
}
|
||||
}
|
||||
|
||||
var helpAnchor: String? {
|
||||
switch self {
|
||||
case .badCredentials: return "Please try again."
|
||||
case .registrationClosed: return "Please try another domain."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticateInfo {
|
||||
let domain: String
|
||||
let clientID: String
|
||||
let clientSecret: String
|
||||
let authorizeURL: URL
|
||||
|
||||
init?(domain: String, application: Mastodon.Entity.Application) {
|
||||
self.domain = domain
|
||||
guard let clientID = application.clientID,
|
||||
let clientSecret = application.clientSecret else { return nil }
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.authorizeURL = {
|
||||
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID)
|
||||
let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query)
|
||||
return url
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func authenticate(info: AuthenticateInfo, pinCodePublisher: PassthroughSubject<String, Never>) {
|
||||
pinCodePublisher
|
||||
.handleEvents(receiveOutput: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
// self.isAuthenticating.value = true
|
||||
self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil)
|
||||
self.mastodonPinBasedAuthenticationViewController = nil
|
||||
})
|
||||
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
|
||||
guard let self = self else { return nil }
|
||||
return self.context.apiService
|
||||
.userAccessToken(
|
||||
domain: info.domain,
|
||||
clientID: info.clientID,
|
||||
clientSecret: info.clientSecret,
|
||||
code: code
|
||||
)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||
let token = response.value
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in success. Token: %s", ((#file as NSString).lastPathComponent), #line, #function, token.accessToken)
|
||||
return Self.verifyAndSaveAuthentication(
|
||||
context: self.context,
|
||||
info: info,
|
||||
userToken: token
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.switchToLatest()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
// self.isAuthenticating.value = false
|
||||
self.error.send(error)
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
let account = response.value
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s sign in success", ((#file as NSString).lastPathComponent), #line, #function, account.username)
|
||||
|
||||
self.authenticated.send((domain: info.domain, account: account))
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
}
|
||||
|
||||
static func verifyAndSaveAuthentication(
|
||||
context: AppContext,
|
||||
info: AuthenticateInfo,
|
||||
userToken: Mastodon.Entity.Token
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||
let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken)
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
|
||||
return context.apiService.accountVerifyCredentials(
|
||||
domain: info.domain,
|
||||
authorization: authorization
|
||||
)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||
let account = response.value
|
||||
let mastodonUserRequest = MastodonUser.sortedFetchRequest
|
||||
mastodonUserRequest.predicate = MastodonUser.predicate(domain: info.domain, id: account.id)
|
||||
mastodonUserRequest.fetchLimit = 1
|
||||
guard let mastodonUser = try? managedObjectContext.fetch(mastodonUserRequest).first else {
|
||||
return Fail(error: AuthenticationError.badCredentials).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let property = MastodonAuthentication.Property(
|
||||
domain: info.domain,
|
||||
userID: mastodonUser.id,
|
||||
username: mastodonUser.username,
|
||||
appAccessToken: userToken.accessToken, // TODO: swap app token
|
||||
userAccessToken: userToken.accessToken,
|
||||
clientID: info.clientID,
|
||||
clientSecret: info.clientSecret
|
||||
)
|
||||
return managedObjectContext.performChanges {
|
||||
_ = APIService.CoreData.createOrMergeMastodonAuthentication(
|
||||
into: managedObjectContext,
|
||||
for: mastodonUser,
|
||||
in: info.domain,
|
||||
property: property,
|
||||
networkDate: response.networkDate
|
||||
)
|
||||
}
|
||||
.tryMap { result in
|
||||
switch result {
|
||||
case .failure(let error): throw error
|
||||
case .success: return response
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SignUp methods & structs
|
||||
extension MastodonPickServerViewModel {
|
||||
|
|
|
@ -52,6 +52,8 @@ class PickServerSearchCell: UITableViewCell {
|
|||
textField.clearButtonMode = .whileEditing
|
||||
textField.autocapitalizationType = .none
|
||||
textField.autocorrectionType = .no
|
||||
textField.returnKeyType = .done
|
||||
textField.keyboardType = .URL
|
||||
return textField
|
||||
}()
|
||||
|
||||
|
@ -78,6 +80,7 @@ extension PickServerSearchCell {
|
|||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
|
||||
searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
|
||||
searchTextField.delegate = self
|
||||
|
||||
contentView.addSubview(bgView)
|
||||
contentView.addSubview(textFieldBgView)
|
||||
|
@ -107,3 +110,12 @@ extension PickServerSearchCell {
|
|||
delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITextFieldDelegate
|
||||
extension PickServerSearchCell: UITextFieldDelegate {
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField.resignFirstResponder()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
//
|
||||
// MastodonPinBasedAuthenticationViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021/1/29.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import WebKit
|
||||
|
||||
final class MastodonPinBasedAuthenticationViewController: UIViewController, NeedsDependency {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: MastodonPinBasedAuthenticationViewModel!
|
||||
|
||||
let webView: WKWebView = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.processPool = WKProcessPool()
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
return webView
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
// cleanup cookie
|
||||
let httpCookieStore = webView.configuration.websiteDataStore.httpCookieStore
|
||||
httpCookieStore.getAllCookies { cookies in
|
||||
for cookie in cookies {
|
||||
httpCookieStore.delete(cookie, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonPinBasedAuthenticationViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
title = "Authentication"
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(MastodonPinBasedAuthenticationViewController.cancelBarButtonItemPressed(_:)))
|
||||
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(webView)
|
||||
NSLayoutConstraint.activate([
|
||||
webView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
let request = URLRequest(url: viewModel.authenticateURL)
|
||||
webView.navigationDelegate = viewModel.navigationDelegate
|
||||
webView.load(request)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: authenticate via: %s", ((#file as NSString).lastPathComponent), #line, #function, viewModel.authenticateURL.debugDescription)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonPinBasedAuthenticationViewController {
|
||||
|
||||
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
//
|
||||
// MastodonPinBasedAuthenticationViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021/1/29.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
import WebKit
|
||||
|
||||
final class MastodonPinBasedAuthenticationViewModel {
|
||||
|
||||
// input
|
||||
let authenticateURL: URL
|
||||
|
||||
// output
|
||||
let pinCodePublisher = PassthroughSubject<String, Never>()
|
||||
private var navigationDelegateShim: MastodonPinBasedAuthenticationViewModelNavigationDelegateShim?
|
||||
|
||||
init(authenticateURL: URL) {
|
||||
self.authenticateURL = authenticateURL
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonPinBasedAuthenticationViewModel {
|
||||
|
||||
var navigationDelegate: WKNavigationDelegate {
|
||||
let navigationDelegateShim = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim(viewModel: self)
|
||||
self.navigationDelegateShim = navigationDelegateShim
|
||||
return navigationDelegateShim
|
||||
}
|
||||
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
//
|
||||
// MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/1/29.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
final class MastodonPinBasedAuthenticationViewModelNavigationDelegateShim: NSObject {
|
||||
|
||||
weak var viewModel: MastodonPinBasedAuthenticationViewModel?
|
||||
|
||||
init(viewModel: MastodonPinBasedAuthenticationViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
extension MastodonPinBasedAuthenticationViewModelNavigationDelegateShim: WKNavigationDelegate {
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard let url = webView.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }),
|
||||
let code = codeQueryItem.value else {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel?.pinCodePublisher.send(code)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -31,9 +31,7 @@ final class AuthenticationViewModel {
|
|||
let isIdle = CurrentValueSubject<Bool, Never>(true)
|
||||
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
|
||||
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||
|
||||
var mastodonPinBasedAuthenticationViewController: UIViewController?
|
||||
|
||||
|
||||
init(context: AppContext, coordinator: SceneCoordinator, isAuthenticationExist: Bool) {
|
||||
self.context = context
|
||||
self.coordinator = coordinator
|
||||
|
@ -118,18 +116,24 @@ extension AuthenticationViewModel {
|
|||
let clientID: String
|
||||
let clientSecret: String
|
||||
let authorizeURL: URL
|
||||
let redirectURI: String
|
||||
|
||||
init?(domain: String, application: Mastodon.Entity.Application) {
|
||||
init?(
|
||||
domain: String,
|
||||
application: Mastodon.Entity.Application,
|
||||
redirectURI: String = MastodonAuthenticationController.callbackURL
|
||||
) {
|
||||
self.domain = domain
|
||||
guard let clientID = application.clientID,
|
||||
let clientSecret = application.clientSecret else { return nil }
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.authorizeURL = {
|
||||
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID)
|
||||
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID, redirectURI: redirectURI)
|
||||
let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query)
|
||||
return url
|
||||
}()
|
||||
self.redirectURI = redirectURI
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,8 +142,6 @@ extension AuthenticationViewModel {
|
|||
.handleEvents(receiveOutput: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.isAuthenticating.value = true
|
||||
self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil)
|
||||
self.mastodonPinBasedAuthenticationViewController = nil
|
||||
})
|
||||
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
|
||||
guard let self = self else { return nil }
|
||||
|
@ -148,6 +150,7 @@ extension AuthenticationViewModel {
|
|||
domain: info.domain,
|
||||
clientID: info.clientID,
|
||||
clientSecret: info.clientSecret,
|
||||
redirectURI: info.redirectURI,
|
||||
code: code
|
||||
)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
//
|
||||
// MastodonAuthenticationController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-4.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import AuthenticationServices
|
||||
|
||||
final class MastodonAuthenticationController {
|
||||
|
||||
static let callbackURLScheme = "mastodon"
|
||||
static let callbackURL = "mastodon://joinmastodon.org/oauth"
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
var context: AppContext!
|
||||
let authenticateURL: URL
|
||||
var authenticationSession: ASWebAuthenticationSession?
|
||||
|
||||
// output
|
||||
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
|
||||
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||
let pinCodePublisher = PassthroughSubject<String, Never>()
|
||||
|
||||
init(
|
||||
context: AppContext,
|
||||
authenticateURL: URL
|
||||
) {
|
||||
self.context = context
|
||||
self.authenticateURL = authenticateURL
|
||||
|
||||
authentication()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonAuthenticationController {
|
||||
private func authentication() {
|
||||
authenticationSession = ASWebAuthenticationSession(
|
||||
url: authenticateURL,
|
||||
callbackURLScheme: MastodonAuthenticationController.callbackURLScheme
|
||||
) { [weak self] callback, error in
|
||||
guard let self = self else { return }
|
||||
os_log("%{public}s[%{public}ld], %{public}s: callback: %s, error: %s", ((#file as NSString).lastPathComponent), #line, #function, callback?.debugDescription ?? "<nil>", error.debugDescription)
|
||||
|
||||
if let error = error {
|
||||
if let error = error as? ASWebAuthenticationSessionError {
|
||||
if error.errorCode == ASWebAuthenticationSessionError.canceledLogin.rawValue {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user cancel authentication", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
self.isAuthenticating.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
self.isAuthenticating.value = false
|
||||
self.error.value = error
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = callback,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }),
|
||||
let code = codeQueryItem.value else {
|
||||
return
|
||||
}
|
||||
|
||||
self.pinCodePublisher.send(code)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import os.log
|
|||
import UIKit
|
||||
import Combine
|
||||
import PhotosUI
|
||||
import ActiveLabel
|
||||
import AlamofireImage
|
||||
import CropViewController
|
||||
import TwitterTextEditor
|
||||
|
@ -16,6 +17,7 @@ import TwitterTextEditor
|
|||
protocol ProfileHeaderViewControllerDelegate: AnyObject {
|
||||
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
|
||||
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int)
|
||||
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
||||
}
|
||||
|
||||
final class ProfileHeaderViewController: UIViewController {
|
||||
|
@ -96,6 +98,15 @@ extension ProfileHeaderViewController {
|
|||
])
|
||||
profileHeaderView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
profileHeaderView.fieldCollectionView.delegate = self
|
||||
viewModel.setupProfileFieldCollectionViewDiffableDataSource(
|
||||
collectionView: profileHeaderView.fieldCollectionView,
|
||||
profileFieldCollectionViewCellDelegate: self,
|
||||
profileFieldAddEntryCollectionViewCellDelegate: self
|
||||
)
|
||||
let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ProfileHeaderViewController.longPressReorderGestureHandler(_:)))
|
||||
profileHeaderView.fieldCollectionView.addGestureRecognizer(longPressReorderGesture)
|
||||
|
||||
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(pageSegmentedControl)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -190,6 +201,17 @@ extension ProfileHeaderViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
viewModel.isEditing,
|
||||
viewModel.displayProfileInfo.fields
|
||||
)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] isEditing, fields in
|
||||
guard let self = self else { return }
|
||||
self.profileHeaderView.fieldCollectionView.isHidden = isEditing ? false : fields.isEmpty
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
profileHeaderView.editAvatarButton.menu = createAvatarContextMenu()
|
||||
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
|
||||
}
|
||||
|
@ -198,14 +220,6 @@ extension ProfileHeaderViewController {
|
|||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppear.value = true
|
||||
|
||||
// Deprecated:
|
||||
// not needs this tweak due to force layout update in the parent
|
||||
// if !isAdjustBannerImageViewForSafeAreaInset {
|
||||
// isAdjustBannerImageViewForSafeAreaInset = true
|
||||
// profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top
|
||||
// profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top
|
||||
// }
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
|
@ -265,6 +279,48 @@ extension ProfileHeaderViewController {
|
|||
delegate?.profileHeaderViewController(self, pageSegmentedControlValueChanged: sender, selectedSegmentIndex: sender.selectedSegmentIndex)
|
||||
}
|
||||
|
||||
// seealso: ProfileHeaderViewModel.setupProfileFieldCollectionViewDiffableDataSource(…)
|
||||
@objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) {
|
||||
guard sender.view === profileHeaderView.fieldCollectionView else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
let collectionView = profileHeaderView.fieldCollectionView
|
||||
switch(sender.state) {
|
||||
case .began:
|
||||
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
|
||||
let cell = collectionView.cellForItem(at: selectedIndexPath) as? ProfileFieldCollectionViewCell else {
|
||||
break
|
||||
}
|
||||
// check if pressing reorder bar no not
|
||||
let locationInCell = sender.location(in: cell.reorderBarImageView)
|
||||
guard cell.reorderBarImageView.bounds.contains(locationInCell) else {
|
||||
return
|
||||
}
|
||||
|
||||
collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
|
||||
case .changed:
|
||||
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
|
||||
let diffableDataSource = viewModel.fieldDiffableDataSource else {
|
||||
break
|
||||
}
|
||||
guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath),
|
||||
case .field = item else {
|
||||
collectionView.cancelInteractiveMovement()
|
||||
return
|
||||
}
|
||||
|
||||
var position = sender.location(in: collectionView)
|
||||
position.x = collectionView.frame.width * 0.5
|
||||
collectionView.updateInteractiveMovementTargetPosition(position)
|
||||
case .ended:
|
||||
collectionView.endInteractiveMovement()
|
||||
collectionView.reloadData()
|
||||
default:
|
||||
collectionView.cancelInteractiveMovement()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileHeaderViewController {
|
||||
|
@ -290,7 +346,7 @@ extension ProfileHeaderViewController {
|
|||
}
|
||||
}
|
||||
|
||||
func updateHeaderScrollProgress(_ progress: CGFloat) {
|
||||
func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) {
|
||||
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
|
||||
updateHeaderBottomShadow(progress: progress)
|
||||
|
||||
|
@ -336,12 +392,12 @@ extension ProfileHeaderViewController {
|
|||
viewModel.isTitleViewContentOffsetSet.value = true
|
||||
}
|
||||
|
||||
// set avatar
|
||||
// set avatar fade
|
||||
if progress > 0 {
|
||||
setProfileBannerFade(alpha: 0)
|
||||
} else if progress > -0.3 {
|
||||
// y = -(10/3)x
|
||||
let alpha = -10.0 / 3.0 * progress
|
||||
} else if progress > -abs(throttle) {
|
||||
// y = -(1/0.8T)x
|
||||
let alpha = -1 / abs(0.8 * throttle) * progress
|
||||
setProfileBannerFade(alpha: alpha)
|
||||
} else {
|
||||
setProfileBannerFade(alpha: 1)
|
||||
|
@ -384,9 +440,9 @@ extension ProfileHeaderViewController: PHPickerViewControllerDelegate {
|
|||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] imageData in
|
||||
} receiveValue: { [weak self] file in
|
||||
guard let self = self else { return }
|
||||
guard let imageData = imageData else { return }
|
||||
guard let imageData = file?.data else { return }
|
||||
guard let image = UIImage(data: imageData) else { return }
|
||||
self.cropImage(image: image, pickerViewController: picker)
|
||||
}
|
||||
|
@ -435,3 +491,28 @@ extension ProfileHeaderViewController: CropViewControllerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - UICollectionViewDelegate
|
||||
extension ProfileHeaderViewController: UICollectionViewDelegate {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ProfileFieldCollectionViewCellDelegate
|
||||
extension ProfileHeaderViewController: ProfileFieldCollectionViewCellDelegate {
|
||||
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, editButtonDidPressed button: UIButton) {
|
||||
guard let diffableDataSource = viewModel.fieldDiffableDataSource else { return }
|
||||
guard let indexPath = profileHeaderView.fieldCollectionView.indexPath(for: cell) else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
viewModel.removeFieldItem(item: item)
|
||||
}
|
||||
|
||||
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
|
||||
delegate?.profileHeaderViewController(self, profileFieldCollectionViewCell: cell, activeLabel: activeLabel, didSelectActiveEntity: entity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProfileFieldAddEntryCollectionViewCellDelegate
|
||||
extension ProfileHeaderViewController: ProfileFieldAddEntryCollectionViewCellDelegate {
|
||||
func ProfileFieldAddEntryCollectionViewCellDidPressed(_ cell: ProfileFieldAddEntryCollectionViewCell) {
|
||||
viewModel.appendFieldItem()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
//
|
||||
// ProfileHeaderViewModel+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension ProfileHeaderViewModel {
|
||||
func setupProfileFieldCollectionViewDiffableDataSource(
|
||||
collectionView: UICollectionView,
|
||||
profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate,
|
||||
profileFieldAddEntryCollectionViewCellDelegate: ProfileFieldAddEntryCollectionViewCellDelegate
|
||||
) {
|
||||
let diffableDataSource = ProfileFieldSection.collectionViewDiffableDataSource(
|
||||
for: collectionView,
|
||||
profileFieldCollectionViewCellDelegate: profileFieldCollectionViewCellDelegate,
|
||||
profileFieldAddEntryCollectionViewCellDelegate: profileFieldAddEntryCollectionViewCellDelegate
|
||||
)
|
||||
|
||||
diffableDataSource.reorderingHandlers.canReorderItem = { item in
|
||||
switch item {
|
||||
case .field: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in
|
||||
guard let self = self else { return }
|
||||
|
||||
let items = transaction.finalSnapshot.itemIdentifiers
|
||||
var fieldValues: [ProfileFieldItem.FieldValue] = []
|
||||
for item in items {
|
||||
guard case let .field(field, _) = item else { continue }
|
||||
fieldValues.append(field)
|
||||
}
|
||||
self.editProfileInfo.fields.value = fieldValues
|
||||
}
|
||||
|
||||
fieldDiffableDataSource = diffableDataSource
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
// Created by MainasuK Cirno on 2021-4-9.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import Kanna
|
||||
|
@ -12,6 +13,8 @@ import MastodonSDK
|
|||
|
||||
final class ProfileHeaderViewModel {
|
||||
|
||||
static let maxProfileFieldCount = 4
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
|
@ -20,11 +23,13 @@ final class ProfileHeaderViewModel {
|
|||
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
|
||||
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
|
||||
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false)
|
||||
let emojiDict = CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>([:])
|
||||
|
||||
// output
|
||||
let displayProfileInfo = ProfileInfo()
|
||||
let editProfileInfo = ProfileInfo()
|
||||
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false)
|
||||
var fieldDiffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>!
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
|
@ -35,11 +40,64 @@ final class ProfileHeaderViewModel {
|
|||
.sink { [weak self] isEditing in
|
||||
guard let self = self else { return }
|
||||
// setup editing value when toggle to editing
|
||||
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
|
||||
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
|
||||
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
|
||||
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
|
||||
self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value)
|
||||
self.editProfileInfo.fields.value = self.displayProfileInfo.fields.value.map { $0.duplicate() } // set to fields
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest4(
|
||||
isEditing.removeDuplicates(),
|
||||
displayProfileInfo.fields.removeDuplicates(),
|
||||
editProfileInfo.fields.removeDuplicates(),
|
||||
emojiDict.removeDuplicates()
|
||||
)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] isEditing, displayFields, editingFields, emojiDict in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.fieldDiffableDataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
let oldSnapshot = diffableDataSource.snapshot()
|
||||
let oldFieldAttributeDict: [UUID: ProfileFieldItem.FieldItemAttribute] = {
|
||||
var dict: [UUID: ProfileFieldItem.FieldItemAttribute] = [:]
|
||||
for item in oldSnapshot.itemIdentifiers {
|
||||
switch item {
|
||||
case .field(let field, let attribute):
|
||||
dict[field.id] = attribute
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
let fields: [ProfileFieldItem.FieldValue] = isEditing ? editingFields : displayFields
|
||||
var items = fields.map { field -> ProfileFieldItem in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: process field item ID: %s", ((#file as NSString).lastPathComponent), #line, #function, field.id.uuidString)
|
||||
|
||||
let attribute = oldFieldAttributeDict[field.id] ?? ProfileFieldItem.FieldItemAttribute()
|
||||
attribute.isEditing = isEditing
|
||||
attribute.emojiDict.value = emojiDict
|
||||
attribute.isLast = false
|
||||
return ProfileFieldItem.field(field: field, attribute: attribute)
|
||||
}
|
||||
|
||||
if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount {
|
||||
items.append(.addEntry(attribute: ProfileFieldItem.AddEntryItemAttribute()))
|
||||
}
|
||||
|
||||
if let last = items.last?.listSeparatorLineConfigurable {
|
||||
last.isLast = true
|
||||
}
|
||||
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -49,6 +107,7 @@ extension ProfileHeaderViewModel {
|
|||
let name = CurrentValueSubject<String?, Never>(nil)
|
||||
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
|
||||
let note = CurrentValueSubject<String?, Never>(nil)
|
||||
let fields = CurrentValueSubject<[ProfileFieldItem.FieldValue], Never>([])
|
||||
|
||||
enum ImageResource {
|
||||
case url(URL?)
|
||||
|
@ -57,6 +116,23 @@ extension ProfileHeaderViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
extension ProfileHeaderViewModel {
|
||||
func appendFieldItem() {
|
||||
var fields = editProfileInfo.fields.value
|
||||
guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return }
|
||||
fields.append(ProfileFieldItem.FieldValue(name: "", value: ""))
|
||||
editProfileInfo.fields.value = fields
|
||||
}
|
||||
|
||||
func removeFieldItem(item: ProfileFieldItem) {
|
||||
var fields = editProfileInfo.fields.value
|
||||
guard case let .field(field, _) = item else { return }
|
||||
guard let removeIndex = fields.firstIndex(of: field) else { return }
|
||||
fields.remove(at: removeIndex)
|
||||
editProfileInfo.fields.value = fields
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileHeaderViewModel {
|
||||
|
||||
static func normalize(note: String?) -> String? {
|
||||
|
@ -75,6 +151,19 @@ extension ProfileHeaderViewModel {
|
|||
guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true }
|
||||
guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true }
|
||||
guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true }
|
||||
let isFieldsEqual: Bool = {
|
||||
let editFields = editProfileInfo.fields.value
|
||||
let displayFields = displayProfileInfo.fields.value
|
||||
guard editFields.count == displayFields.count else { return false }
|
||||
for (editField, displayField) in zip(editFields, displayFields) {
|
||||
guard editField.name.value == displayField.name.value,
|
||||
editField.value.value == displayField.value.value else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}()
|
||||
guard isFieldsEqual else { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -95,6 +184,10 @@ extension ProfileHeaderViewModel {
|
|||
return image
|
||||
}()
|
||||
|
||||
let fieldsAttributes = editProfileInfo.fields.value.map { fieldValue in
|
||||
Mastodon.Entity.Field(name: fieldValue.name.value, value: fieldValue.value.value)
|
||||
}
|
||||
|
||||
let query = Mastodon.API.Account.UpdateCredentialQuery(
|
||||
discoverable: nil,
|
||||
bot: nil,
|
||||
|
@ -104,7 +197,7 @@ extension ProfileHeaderViewModel {
|
|||
header: nil,
|
||||
locked: nil,
|
||||
source: nil,
|
||||
fieldsAttributes: nil // TODO:
|
||||
fieldsAttributes: fieldsAttributes
|
||||
)
|
||||
return context.apiService.accountUpdateCredentials(
|
||||
domain: domain,
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
//
|
||||
// ProfileFieldAddEntryCollectionViewCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-26.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
protocol ProfileFieldAddEntryCollectionViewCellDelegate: AnyObject {
|
||||
func ProfileFieldAddEntryCollectionViewCellDidPressed(_ cell: ProfileFieldAddEntryCollectionViewCell)
|
||||
}
|
||||
|
||||
final class ProfileFieldAddEntryCollectionViewCell: UICollectionViewCell {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
weak var delegate: ProfileFieldAddEntryCollectionViewCellDelegate?
|
||||
|
||||
let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
|
||||
|
||||
static let symbolConfiguration = ProfileFieldCollectionViewCell.symbolConfiguration
|
||||
static let insertButtonImage = UIImage(systemName: "plus.circle.fill", withConfiguration: symbolConfiguration)
|
||||
|
||||
let containerStackView = UIStackView()
|
||||
|
||||
let fieldView = ProfileFieldView()
|
||||
|
||||
let editButton: UIButton = {
|
||||
let button = HitTestExpandedButton(type: .custom)
|
||||
button.setImage(ProfileFieldAddEntryCollectionViewCell.insertButtonImage, for: .normal)
|
||||
button.contentMode = .center
|
||||
button.tintColor = .systemGreen
|
||||
return button
|
||||
}()
|
||||
|
||||
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
||||
var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
|
||||
var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
|
||||
let bottomSeparatorLine = UIView.separatorLine
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
disposeBag.removeAll()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileFieldAddEntryCollectionViewCell {
|
||||
|
||||
private func _init() {
|
||||
containerStackView.axis = .horizontal
|
||||
containerStackView.spacing = 8
|
||||
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
containerStackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
containerStackView.addArrangedSubview(editButton)
|
||||
containerStackView.addArrangedSubview(fieldView)
|
||||
|
||||
editButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
|
||||
editButton.setContentHuggingPriority(.required - 1, for: .horizontal)
|
||||
|
||||
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
separatorLineToMarginLeadingLayoutConstraint = bottomSeparatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
|
||||
separatorLineToEdgeTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
|
||||
separatorLineToMarginTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor)
|
||||
|
||||
addSubview(bottomSeparatorLine)
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToMarginLeadingLayoutConstraint,
|
||||
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
fieldView.titleActiveLabel.isHidden = false
|
||||
fieldView.titleActiveLabel.configure(field: L10n.Scene.Profile.Fields.addRow, emojiDict: [:])
|
||||
fieldView.titleTextField.isHidden = true
|
||||
|
||||
fieldView.valueActiveLabel.isHidden = false
|
||||
fieldView.valueActiveLabel.configure(field: " ", emojiDict: [:])
|
||||
fieldView.valueTextField.isHidden = true
|
||||
|
||||
addGestureRecognizer(singleTagGestureRecognizer)
|
||||
singleTagGestureRecognizer.addTarget(self, action: #selector(ProfileFieldAddEntryCollectionViewCell.singleTapGestureRecognizerHandler(_:)))
|
||||
|
||||
editButton.addTarget(self, action: #selector(ProfileFieldAddEntryCollectionViewCell.addButtonDidPressed(_:)), for: .touchUpInside)
|
||||
|
||||
resetSeparatorLineLayout()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
resetSeparatorLineLayout()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileFieldAddEntryCollectionViewCell {
|
||||
|
||||
@objc private func singleTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.ProfileFieldAddEntryCollectionViewCellDidPressed(self)
|
||||
}
|
||||
|
||||
@objc private func addButtonDidPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.ProfileFieldAddEntryCollectionViewCellDidPressed(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileFieldAddEntryCollectionViewCell {
|
||||
private func resetSeparatorLineLayout() {
|
||||
separatorLineToEdgeTrailingLayoutConstraint.isActive = false
|
||||
separatorLineToMarginTrailingLayoutConstraint.isActive = false
|
||||
|
||||
if traitCollection.userInterfaceIdiom == .phone {
|
||||
// to edge
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToEdgeTrailingLayoutConstraint,
|
||||
])
|
||||
} else {
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
// to edge
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToEdgeTrailingLayoutConstraint,
|
||||
])
|
||||
} else {
|
||||
// to margin
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToMarginTrailingLayoutConstraint,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileFieldAddEntryCollectionViewCell_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
UIViewPreview(width: 375) {
|
||||
ProfileFieldAddEntryCollectionViewCell()
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
//
|
||||
// ProfileFieldCollectionViewCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-25.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import ActiveLabel
|
||||
|
||||
protocol ProfileFieldCollectionViewCellDelegate: AnyObject {
|
||||
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, editButtonDidPressed button: UIButton)
|
||||
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
||||
}
|
||||
|
||||
final class ProfileFieldCollectionViewCell: UICollectionViewCell {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
weak var delegate: ProfileFieldCollectionViewCellDelegate?
|
||||
|
||||
static let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold, scale: .medium)
|
||||
static let removeButtonItem = UIImage(systemName: "minus.circle.fill", withConfiguration: symbolConfiguration)
|
||||
|
||||
let containerStackView = UIStackView()
|
||||
|
||||
let fieldView = ProfileFieldView()
|
||||
|
||||
let editButton: UIButton = {
|
||||
let button = HitTestExpandedButton(type: .custom)
|
||||
button.setImage(ProfileFieldCollectionViewCell.removeButtonItem, for: .normal)
|
||||
button.contentMode = .center
|
||||
button.tintColor = .systemRed
|
||||
return button
|
||||
}()
|
||||
|
||||
let reorderBarImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.image = UIImage(systemName: "line.horizontal.3")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)).withRenderingMode(.alwaysTemplate)
|
||||
imageView.tintColor = Asset.Colors.Label.secondary.color
|
||||
return imageView
|
||||
}()
|
||||
|
||||
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
||||
var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
|
||||
var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
|
||||
let bottomSeparatorLine = UIView.separatorLine
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
disposeBag.removeAll()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileFieldCollectionViewCell {
|
||||
|
||||
private func _init() {
|
||||
containerStackView.axis = .horizontal
|
||||
containerStackView.spacing = 8
|
||||
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
containerStackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
containerStackView.addArrangedSubview(editButton)
|
||||
containerStackView.addArrangedSubview(fieldView)
|
||||
containerStackView.addArrangedSubview(reorderBarImageView)
|
||||
|
||||
editButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
|
||||
editButton.setContentHuggingPriority(.required - 1, for: .horizontal)
|
||||
reorderBarImageView.setContentHuggingPriority(.required - 1, for: .horizontal)
|
||||
reorderBarImageView.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
|
||||
|
||||
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
separatorLineToMarginLeadingLayoutConstraint = bottomSeparatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
|
||||
separatorLineToEdgeTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
|
||||
separatorLineToMarginTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor)
|
||||
|
||||
addSubview(bottomSeparatorLine)
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToMarginLeadingLayoutConstraint,
|
||||
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
editButton.addTarget(self, action: #selector(ProfileFieldCollectionViewCell.editButtonDidPressed(_:)), for: .touchUpInside)
|
||||
|
||||
fieldView.valueActiveLabel.delegate = self
|
||||
|
||||
resetSeparatorLineLayout()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
resetSeparatorLineLayout()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileFieldCollectionViewCell {
|
||||
private func resetSeparatorLineLayout() {
|
||||
separatorLineToEdgeTrailingLayoutConstraint.isActive = false
|
||||
separatorLineToMarginTrailingLayoutConstraint.isActive = false
|
||||
|
||||
if traitCollection.userInterfaceIdiom == .phone {
|
||||
// to edge
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToEdgeTrailingLayoutConstraint,
|
||||
])
|
||||
} else {
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
// to edge
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToEdgeTrailingLayoutConstraint,
|
||||
])
|
||||
} else {
|
||||
// to margin
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToMarginTrailingLayoutConstraint,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileFieldCollectionViewCell {
|
||||
@objc private func editButtonDidPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.profileFieldCollectionViewCell(self, editButtonDidPressed: sender)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - ActiveLabelDelegate
|
||||
extension ProfileFieldCollectionViewCell: ActiveLabelDelegate {
|
||||
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.profileFieldCollectionViewCell(self, activeLabel: activeLabel, didSelectActiveEntity: entity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileFieldCollectionViewCell_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
UIViewPreview(width: 375) {
|
||||
ProfileFieldCollectionViewCell()
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// ProfileFieldCollectionViewHeaderFooterView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ProfileFieldCollectionViewHeaderFooterView: UICollectionReusableView {
|
||||
|
||||
static let headerReuseIdentifer = "ProfileFieldCollectionViewHeaderFooterView.Header"
|
||||
static let footerReuseIdentifer = "ProfileFieldCollectionViewHeaderFooterView.Footer"
|
||||
|
||||
let separatorLine = UIView.separatorLine
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileFieldCollectionViewHeaderFooterView {
|
||||
private func _init() {
|
||||
separatorLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(separatorLine)
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLine.topAnchor.constraint(equalTo: topAnchor),
|
||||
// workaround SDK supplementariesFollowContentInsets not works issue
|
||||
separatorLine.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -9999),
|
||||
separatorLine.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 9999),
|
||||
separatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
|
||||
])
|
||||
}
|
||||
}
|
|
@ -6,26 +6,49 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import ActiveLabel
|
||||
|
||||
final class ProfileFieldView: UIView {
|
||||
|
||||
let titleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold))
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.text = "Title"
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// output
|
||||
let name = PassthroughSubject<String, Never>()
|
||||
let value = PassthroughSubject<String, Never>()
|
||||
|
||||
// for custom emoji display
|
||||
let titleActiveLabel: ActiveLabel = {
|
||||
let label = ActiveLabel(style: .profileFieldName)
|
||||
label.configure(content: "title", emojiDict: [:])
|
||||
return label
|
||||
}()
|
||||
|
||||
// for editing
|
||||
let titleTextField: UITextField = {
|
||||
let textField = UITextField()
|
||||
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20)
|
||||
textField.textColor = Asset.Colors.Label.primary.color
|
||||
textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.label
|
||||
return textField
|
||||
}()
|
||||
|
||||
// for custom emoji display
|
||||
let valueActiveLabel: ActiveLabel = {
|
||||
let label = ActiveLabel(style: .profileField)
|
||||
let label = ActiveLabel(style: .profileFieldValue)
|
||||
label.configure(content: "value", emojiDict: [:])
|
||||
return label
|
||||
}()
|
||||
|
||||
let topSeparatorLine = UIView.separatorLine
|
||||
let bottomSeparatorLine = UIView.separatorLine
|
||||
// for editing
|
||||
let valueTextField: UITextField = {
|
||||
let textField = UITextField()
|
||||
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20)
|
||||
textField.textColor = Asset.Colors.Label.primary.color
|
||||
textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.content
|
||||
textField.textAlignment = .right
|
||||
return textField
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
@ -41,42 +64,67 @@ final class ProfileFieldView: UIView {
|
|||
|
||||
extension ProfileFieldView {
|
||||
private func _init() {
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(titleLabel)
|
||||
|
||||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .horizontal
|
||||
containerStackView.alignment = .center
|
||||
|
||||
// note:
|
||||
// do not use readable layout guide to workaround SDK issue
|
||||
// otherwise, the `ProfileFieldCollectionViewCell` cannot display edit button and reorder icon
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
titleLabel.topAnchor.constraint(equalTo: topAnchor),
|
||||
titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
|
||||
titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
titleLabel.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
|
||||
containerStackView.topAnchor.constraint(equalTo: topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
titleActiveLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(titleActiveLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
titleActiveLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
titleTextField.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
|
||||
titleTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(titleTextField)
|
||||
NSLayoutConstraint.activate([
|
||||
titleTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
titleTextField.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
|
||||
|
||||
valueActiveLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(valueActiveLabel)
|
||||
containerStackView.addArrangedSubview(valueActiveLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
valueActiveLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
valueActiveLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
|
||||
valueActiveLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
|
||||
valueActiveLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
valueActiveLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
|
||||
topSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(topSeparatorLine)
|
||||
valueActiveLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
valueTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(valueTextField)
|
||||
NSLayoutConstraint.activate([
|
||||
topSeparatorLine.topAnchor.constraint(equalTo: topAnchor),
|
||||
topSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
topSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
topSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
|
||||
valueTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(bottomSeparatorLine)
|
||||
NSLayoutConstraint.activate([
|
||||
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
bottomSeparatorLine.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
|
||||
bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
|
||||
])
|
||||
titleTextField.isHidden = true
|
||||
valueTextField.isHidden = true
|
||||
|
||||
NotificationCenter.default
|
||||
.publisher(for: UITextField.textDidChangeNotification, object: titleTextField)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.name.send(self.titleTextField.text ?? "")
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
NotificationCenter.default
|
||||
.publisher(for: UITextField.textDidChangeNotification, object: valueTextField)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.value.send(self.valueTextField.text ?? "")
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,7 +136,7 @@ struct ProfileFieldView_Previews: PreviewProvider {
|
|||
static var previews: some View {
|
||||
UIViewPreview(width: 375) {
|
||||
let filedView = ProfileFieldView()
|
||||
filedView.valueActiveLabel.configure(field: "https://mastodon.online")
|
||||
filedView.valueActiveLabel.configure(field: "https://mastodon.online", emojiDict: [:])
|
||||
return filedView
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 100))
|
||||
|
|
|
@ -17,8 +17,8 @@ protocol ProfileHeaderViewDelegate: AnyObject {
|
|||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView)
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView)
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView)
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView)
|
||||
}
|
||||
|
||||
final class ProfileHeaderView: UIView {
|
||||
|
@ -80,6 +80,7 @@ final class ProfileHeaderView: UIView {
|
|||
view.layer.masksToBounds = true
|
||||
view.layer.cornerCurve = .continuous
|
||||
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
|
||||
view.alpha = 0 // set initial state invisible
|
||||
return view
|
||||
}()
|
||||
|
||||
|
@ -152,6 +153,38 @@ final class ProfileHeaderView: UIView {
|
|||
return textEditorView
|
||||
}()
|
||||
|
||||
static func createFieldCollectionViewLayout() -> UICollectionViewLayout {
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
|
||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.contentInsetsReference = .readableContent
|
||||
|
||||
let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(1))
|
||||
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
|
||||
let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
|
||||
section.boundarySupplementaryItems = [header, footer]
|
||||
// note: toggle this not take effect
|
||||
// section.supplementariesFollowContentInsets = false
|
||||
|
||||
return UICollectionViewCompositionalLayout(section: section)
|
||||
}
|
||||
|
||||
let fieldCollectionView: UICollectionView = {
|
||||
let collectionViewLayout = ProfileHeaderView.createFieldCollectionViewLayout()
|
||||
let collectionView = UICollectionView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), collectionViewLayout: collectionViewLayout)
|
||||
collectionView.register(ProfileFieldCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self))
|
||||
collectionView.register(ProfileFieldAddEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self))
|
||||
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer)
|
||||
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer)
|
||||
collectionView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
|
||||
collectionView.isScrollEnabled = false
|
||||
return collectionView
|
||||
}()
|
||||
var fieldCollectionViewHeightLaoutConstraint: NSLayoutConstraint!
|
||||
var fieldCollectionViewHeightObservation: NSKeyValueObservation?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
|
@ -162,6 +195,10 @@ final class ProfileHeaderView: UIView {
|
|||
_init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
fieldCollectionViewHeightObservation = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileHeaderView {
|
||||
|
@ -193,24 +230,24 @@ extension ProfileHeaderView {
|
|||
])
|
||||
|
||||
// avatar
|
||||
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bannerContainerView.addSubview(avatarImageView)
|
||||
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bannerContainerView.addSubview(avatarImageViewBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
avatarImageView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
|
||||
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20),
|
||||
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
|
||||
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
|
||||
avatarImageViewBackgroundView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
|
||||
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageViewBackgroundView.bottomAnchor, constant: 20),
|
||||
])
|
||||
|
||||
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView)
|
||||
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatarImageViewBackgroundView.addSubview(avatarImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||
avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||
avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||
avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
|
||||
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
|
||||
])
|
||||
|
||||
|
||||
editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatarImageView.addSubview(editAvatarBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -328,8 +365,20 @@ extension ProfileHeaderView {
|
|||
bioContainerStackView.addArrangedSubview(bioActiveLabelContainer)
|
||||
bioContainerStackView.addArrangedSubview(bioTextEditorView)
|
||||
|
||||
fieldContainerStackView.preservesSuperviewLayoutMargins = true
|
||||
metaContainerStackView.addSubview(fieldContainerStackView)
|
||||
fieldCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
metaContainerStackView.addArrangedSubview(fieldCollectionView)
|
||||
fieldCollectionViewHeightLaoutConstraint = fieldCollectionView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh)
|
||||
NSLayoutConstraint.activate([
|
||||
fieldCollectionViewHeightLaoutConstraint,
|
||||
])
|
||||
fieldCollectionViewHeightObservation = fieldCollectionView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in
|
||||
guard let self = self else { return }
|
||||
guard self.fieldCollectionView.contentSize.height != .zero else {
|
||||
self.fieldCollectionViewHeightLaoutConstraint.constant = 44
|
||||
return
|
||||
}
|
||||
self.fieldCollectionViewHeightLaoutConstraint.constant = self.fieldCollectionView.contentSize.height
|
||||
})
|
||||
|
||||
bringSubviewToFront(bannerContainerView)
|
||||
bringSubviewToFront(nameContainerStackView)
|
||||
|
|
|
@ -368,6 +368,18 @@ extension ProfileViewController {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
|
||||
.store(in: &disposeBag)
|
||||
viewModel.fileds
|
||||
.removeDuplicates()
|
||||
.map { fields -> [ProfileFieldItem.FieldValue] in
|
||||
fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value) }
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.fields)
|
||||
.store(in: &disposeBag)
|
||||
viewModel.emojiDict
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.emojiDict)
|
||||
.store(in: &disposeBag)
|
||||
viewModel.username
|
||||
.map { username in username.flatMap { "@" + $0 } ?? " " }
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
@ -640,7 +652,8 @@ extension ProfileViewController: UIScrollViewDelegate {
|
|||
|
||||
// elastically banner image
|
||||
let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY
|
||||
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress)
|
||||
let throttle = ProfileHeaderViewController.headerMinHeight / topMaxContentOffsetY
|
||||
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress, throttle: throttle)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -664,6 +677,19 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate {
|
|||
)
|
||||
}
|
||||
|
||||
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity 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))
|
||||
case .hashtag(let hashtag, _):
|
||||
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag)
|
||||
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ProfilePagingViewControllerDelegate
|
||||
|
@ -852,20 +878,27 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
|
|||
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))
|
||||
case .mention(_, let userInfo):
|
||||
guard let href = userInfo?["href"] as? String,
|
||||
let url = URL(string: href) else { return }
|
||||
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
|
||||
case .hashtag(let hashtag, _):
|
||||
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag)
|
||||
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show)
|
||||
default:
|
||||
// TODO:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
|
||||
}
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) {
|
||||
|
||||
}
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) {
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView) {
|
||||
|
||||
}
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView) {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -39,6 +39,8 @@ class ProfileViewModel: NSObject {
|
|||
let statusesCount: CurrentValueSubject<Int?, Never>
|
||||
let followingCount: CurrentValueSubject<Int?, Never>
|
||||
let followersCount: CurrentValueSubject<Int?, Never>
|
||||
let fileds: CurrentValueSubject<[Mastodon.Entity.Field], Never>
|
||||
let emojiDict: CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>
|
||||
|
||||
let protected: CurrentValueSubject<Bool?, Never>
|
||||
let suspended: CurrentValueSubject<Bool, Never>
|
||||
|
@ -75,6 +77,8 @@ class ProfileViewModel: NSObject {
|
|||
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
|
||||
self.protected = CurrentValueSubject(mastodonUser?.locked)
|
||||
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
|
||||
self.fileds = CurrentValueSubject(mastodonUser?.fields ?? [])
|
||||
self.emojiDict = CurrentValueSubject(mastodonUser?.emojiDict ?? [:])
|
||||
super.init()
|
||||
|
||||
relationshipActionOptionSet
|
||||
|
@ -231,6 +235,8 @@ extension ProfileViewModel {
|
|||
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
|
||||
self.protected.value = mastodonUser?.locked
|
||||
self.suspended.value = mastodonUser?.suspended ?? false
|
||||
self.fileds.value = mastodonUser?.fields ?? []
|
||||
self.emojiDict.value = mastodonUser?.emojiDict ?? [:]
|
||||
}
|
||||
|
||||
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
|
||||
|
|
|
@ -59,11 +59,6 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
|
|||
|
||||
var items = [Item]()
|
||||
for (_, status) in indexStatusTuples {
|
||||
let targetStatus = status.reblog ?? status
|
||||
let isStatusTextSensitive: Bool = {
|
||||
guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false }
|
||||
return true
|
||||
}()
|
||||
let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute()
|
||||
items.append(Item.status(objectID: status.objectID, attribute: attribute))
|
||||
if statusIDsWhichHasGap.contains(status.id) {
|
||||
|
|
|
@ -12,8 +12,7 @@ import ActiveLabel
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
import Kingfisher
|
||||
|
||||
|
||||
class SettingsViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -95,7 +94,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
|||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.delegate = self
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
tableView.backgroundColor = .clear
|
||||
|
||||
tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self))
|
||||
tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self))
|
||||
|
@ -186,7 +185,14 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
|||
}
|
||||
|
||||
private func setupView() {
|
||||
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
|
||||
view.backgroundColor = UIColor(dynamicProvider: { traitCollection in
|
||||
switch traitCollection.userInterfaceLevel {
|
||||
case .elevated where traitCollection.userInterfaceStyle == .dark:
|
||||
return Asset.Colors.Background.systemElevatedBackground.color
|
||||
default:
|
||||
return Asset.Colors.Background.secondarySystemBackground.color
|
||||
}
|
||||
})
|
||||
setupNavigation()
|
||||
|
||||
view.addSubview(tableView)
|
||||
|
@ -319,36 +325,44 @@ extension SettingsViewController: UITableViewDelegate {
|
|||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let dataSource = viewModel.dataSource else { return }
|
||||
let item = dataSource.itemIdentifier(for: indexPath)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
switch item {
|
||||
case .boringZone:
|
||||
guard let url = viewModel.privacyURL else { break }
|
||||
coordinator.present(
|
||||
scene: .safari(url: url),
|
||||
from: self,
|
||||
transition: .safariPresent(animated: true, completion: nil)
|
||||
)
|
||||
case .spicyZone(let link):
|
||||
// clear media cache
|
||||
if link.title == L10n.Scene.Settings.Section.Spicyzone.clear {
|
||||
// clean image cache for AlamofireImage
|
||||
let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
ImageDownloader.defaultURLCache().removeAllCachedResponses()
|
||||
let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes)
|
||||
|
||||
// clean Kingfisher Cache
|
||||
KingfisherManager.shared.cache.clearDiskCache()
|
||||
}
|
||||
// logout
|
||||
if link.title == L10n.Scene.Settings.Section.Spicyzone.signout {
|
||||
case .apperance:
|
||||
// do nothing
|
||||
break
|
||||
case .notification:
|
||||
// do nothing
|
||||
break
|
||||
case .boringZone(let link), .spicyZone(let link):
|
||||
switch link {
|
||||
case .termsOfService, .privacyPolicy:
|
||||
// same URL
|
||||
guard let url = viewModel.privacyURL else { break }
|
||||
coordinator.present(
|
||||
scene: .safari(url: url),
|
||||
from: self,
|
||||
transition: .safariPresent(animated: true, completion: nil)
|
||||
)
|
||||
case .clearMediaCache:
|
||||
context.purgeCache()
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] byteCount in
|
||||
guard let self = self else { return }
|
||||
let byteCountformatted = AppContext.byteCountFormatter.string(fromByteCount: Int64(byteCount))
|
||||
let alertController = UIAlertController(
|
||||
title: L10n.Common.Alerts.CleanCache.title,
|
||||
message: L10n.Common.Alerts.CleanCache.message(byteCountformatted),
|
||||
preferredStyle: .alert
|
||||
)
|
||||
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||
alertController.addAction(okAction)
|
||||
self.coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
case .signOut:
|
||||
alertToSignout()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -176,7 +176,7 @@ class SettingsAppearanceTableViewCell: UITableViewCell {
|
|||
|
||||
// MARK: Private methods
|
||||
private func setupUI() {
|
||||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
contentView.addSubview(stackView)
|
||||
|
||||
|
|
|
@ -39,7 +39,8 @@ class SettingsSectionHeader: UIView {
|
|||
init(frame: CGRect, customView: UIView? = nil) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
backgroundColor = .clear
|
||||
|
||||
stackView.addArrangedSubview(titleLabel)
|
||||
if let view = customView {
|
||||
stackView.addArrangedSubview(view)
|
||||
|
|
|
@ -395,7 +395,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
|||
let images = self.images.prefix(2)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||
for (i, mosiac) in mosaics.enumerated() {
|
||||
let (imageView, blurhashOverlayImageView) = mosiac
|
||||
let (imageView, _) = mosiac
|
||||
imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
|
@ -407,7 +407,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
|||
let images = self.images.prefix(3)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||
for (i, mosiac) in mosaics.enumerated() {
|
||||
let (imageView, blurhashOverlayImageView) = mosiac
|
||||
let (imageView, _) = mosiac
|
||||
imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
|
@ -419,7 +419,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
|||
let images = self.images.prefix(4)
|
||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||
for (i, mosiac) in mosaics.enumerated() {
|
||||
let (imageView, blurhashOverlayImageView) = mosiac
|
||||
let (imageView, _) = mosiac
|
||||
imageView.image = images[i]
|
||||
}
|
||||
return view
|
||||
|
|
|
@ -46,7 +46,7 @@ struct MosaicMeta {
|
|||
let blurhash: String?
|
||||
let altText: String?
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent)
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent)
|
||||
|
||||
func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> {
|
||||
return Future { promise in
|
||||
|
|
|
@ -14,7 +14,7 @@ import UIKit
|
|||
final class VideoPlayerViewModel {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.video-playback-service.appWillPlayVideo")
|
||||
static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.app.video-playback-service.appWillPlayVideo")
|
||||
// input
|
||||
let previewImageURL: URL?
|
||||
let videoURL: URL
|
||||
|
|
|
@ -111,7 +111,7 @@ extension ThreadViewModel.LoadThreadState {
|
|||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let _ = viewModel, let stateMachine = stateMachine else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
|
|
|
@ -163,11 +163,18 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
var needsMaskWithAnimation = true
|
||||
let maskLayerToRect: CGRect? = {
|
||||
guard case .mosaic = transitionItem.source else { return nil }
|
||||
guard let navigationBar = toVC.navigationController?.navigationBar else { return nil }
|
||||
let navigationBarFrameInWindow = toVC.view.convert(navigationBar.frame, to: nil)
|
||||
var rect = transitionMaskView.frame
|
||||
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
|
||||
guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil }
|
||||
let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil)
|
||||
|
||||
// crop rect top edge
|
||||
var rect = transitionMaskView.frame
|
||||
let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) }
|
||||
if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY {
|
||||
rect.origin.y = toViewFrameInWindow.minY
|
||||
} else {
|
||||
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
|
||||
}
|
||||
|
||||
if rect.minY < snapshot.frame.minY {
|
||||
needsMaskWithAnimation = false
|
||||
}
|
||||
|
@ -177,8 +184,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath
|
||||
let maskLayerToFinalRect: CGRect? = {
|
||||
guard case .mosaic = transitionItem.source else { return nil }
|
||||
guard let tabBarController = toVC.tabBarController else { return nil }
|
||||
let tabBarFrameInWindow = toVC.view.convert(tabBarController.tabBar.frame, to: nil)
|
||||
guard let tabBarController = toVC.tabBarController, let tabBarSuperView = tabBarController.tabBar.superview else { return nil }
|
||||
let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil)
|
||||
var rect = maskLayerToRect ?? transitionMaskView.frame
|
||||
let offset = rect.maxY - tabBarFrameInWindow.minY
|
||||
guard offset > 0 else { return rect }
|
||||
|
@ -238,7 +245,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
|
||||
private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
|
||||
let fromView = transitionContext.view(forKey: .from),
|
||||
let _ = transitionContext.view(forKey: .from),
|
||||
let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController,
|
||||
let index = fromVC.pagingViewConttroller.currentIndex else {
|
||||
fatalError()
|
||||
|
@ -411,11 +418,18 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
var needsMaskWithAnimation = true
|
||||
let maskLayerToRect: CGRect? = {
|
||||
guard case .mosaic = transitionItem.source else { return nil }
|
||||
guard let navigationBar = toVC.navigationController?.navigationBar else { return nil }
|
||||
let navigationBarFrameInWindow = toVC.view.convert(navigationBar.frame, to: nil)
|
||||
var rect = transitionMaskView.frame
|
||||
rect.origin.y = navigationBarFrameInWindow.maxY
|
||||
guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil }
|
||||
let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil)
|
||||
|
||||
// crop rect top edge
|
||||
var rect = transitionMaskView.frame
|
||||
let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) }
|
||||
if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY {
|
||||
rect.origin.y = toViewFrameInWindow.minY
|
||||
} else {
|
||||
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
|
||||
}
|
||||
|
||||
if rect.minY < snapshot.frame.minY {
|
||||
needsMaskWithAnimation = false
|
||||
}
|
||||
|
@ -430,8 +444,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
|
||||
let maskLayerToFinalRect: CGRect? = {
|
||||
guard case .mosaic = transitionItem.source else { return nil }
|
||||
guard let tabBarController = toVC.tabBarController else { return nil }
|
||||
let tabBarFrameInWindow = toVC.view.convert(tabBarController.tabBar.frame, to: nil)
|
||||
guard let tabBarController = toVC.tabBarController, let tabBarSuperView = tabBarController.tabBar.superview else { return nil }
|
||||
let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil)
|
||||
var rect = maskLayerToRect ?? transitionMaskView.frame
|
||||
let offset = rect.maxY - tabBarFrameInWindow.minY
|
||||
guard offset > 0 else { return rect }
|
||||
|
|
|
@ -20,7 +20,11 @@ extension APIService {
|
|||
#endif
|
||||
|
||||
func createApplication(domain: String) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Application>, Error> {
|
||||
let query = Mastodon.API.App.CreateQuery(clientName: APIService.clientName, website: nil)
|
||||
let query = Mastodon.API.App.CreateQuery(
|
||||
clientName: APIService.clientName,
|
||||
redirectURIs: MastodonAuthenticationController.callbackURL,
|
||||
website: nil
|
||||
)
|
||||
return Mastodon.API.App.create(
|
||||
session: session,
|
||||
domain: domain,
|
||||
|
|
|
@ -17,11 +17,13 @@ extension APIService {
|
|||
domain: String,
|
||||
clientID: String,
|
||||
clientSecret: String,
|
||||
redirectURI: String,
|
||||
code: String
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
|
||||
let query = Mastodon.API.OAuth.AccessTokenQuery(
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
redirectURI: redirectURI,
|
||||
code: code,
|
||||
grantType: "authorization_code"
|
||||
)
|
||||
|
@ -35,11 +37,13 @@ extension APIService {
|
|||
func applicationAccessToken(
|
||||
domain: String,
|
||||
clientID: String,
|
||||
clientSecret: String
|
||||
clientSecret: String,
|
||||
redirectURI: String
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
|
||||
let query = Mastodon.API.OAuth.AccessTokenQuery(
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
redirectURI: redirectURI,
|
||||
code: nil,
|
||||
grantType: "client_credentials"
|
||||
)
|
||||
|
|
|
@ -98,6 +98,8 @@ extension APIService.CoreData {
|
|||
user.update(locked: property.locked)
|
||||
property.bot.flatMap { user.update(bot: $0) }
|
||||
property.suspended.flatMap { user.update(suspended: $0) }
|
||||
property.emojisData.flatMap { user.update(emojisData: $0) }
|
||||
property.fieldsData.flatMap { user.update(fieldsData: $0) }
|
||||
|
||||
user.didUpdate(at: networkDate)
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import os.log
|
|||
|
||||
final class AudioPlaybackService: NSObject {
|
||||
|
||||
static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.audio-playback-service.appWillPlayAudio")
|
||||
static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.app.audio-playback-service.appWillPlayAudio")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ final class EmojiService {
|
|||
|
||||
weak var apiService: APIService?
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.EmojiService.working-queue")
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.EmojiService.working-queue")
|
||||
private(set) var customEmojiViewModelDict: [String: CustomEmojiViewModel] = [:]
|
||||
|
||||
init(apiService: APIService) {
|
||||
|
|
|
@ -104,7 +104,7 @@ extension Trie {
|
|||
|
||||
var values: NSSet {
|
||||
let valueSet = NSMutableSet(set: self.valueSet)
|
||||
for (key, value) in children {
|
||||
for (_, value) in children {
|
||||
valueSet.addObjects(from: Array(value.values))
|
||||
}
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ extension MastodonAttachmentService.UploadState {
|
|||
return true
|
||||
}
|
||||
|
||||
if service?.imageData.value != nil {
|
||||
if service?.file.value != nil {
|
||||
return stateClass == Uploading.self
|
||||
} else {
|
||||
return stateClass == Fail.self
|
||||
|
@ -53,15 +53,8 @@ extension MastodonAttachmentService.UploadState {
|
|||
|
||||
guard let service = service, let stateMachine = stateMachine else { return }
|
||||
guard let authenticationBox = service.authenticationBox else { return }
|
||||
guard let imageData = service.imageData.value else { return }
|
||||
guard let file = service.file.value else { return }
|
||||
|
||||
let file: Mastodon.Query.MediaAttachment = {
|
||||
if imageData.kf.imageFormat == .PNG {
|
||||
return .png(imageData)
|
||||
} else {
|
||||
return .jpeg(imageData)
|
||||
}
|
||||
}()
|
||||
let description = service.description.value
|
||||
let query = Mastodon.API.Media.UploadMeidaQuery(
|
||||
file: file,
|
||||
|
@ -81,6 +74,7 @@ extension MastodonAttachmentService.UploadState {
|
|||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
service.error.send(error)
|
||||
stateMachine.enter(Fail.self)
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
break
|
||||
|
|
|
@ -5,11 +5,13 @@
|
|||
// Created by MainasuK Cirno on 2021-3-17.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
import GameplayKit
|
||||
import MobileCoreServices
|
||||
import MastodonSDK
|
||||
|
||||
protocol MastodonAttachmentServiceDelegate: AnyObject {
|
||||
|
@ -22,16 +24,16 @@ final class MastodonAttachmentService {
|
|||
weak var delegate: MastodonAttachmentServiceDelegate?
|
||||
|
||||
let identifier = UUID()
|
||||
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
var authenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
|
||||
let description = CurrentValueSubject<String?, Never>(nil)
|
||||
|
||||
// output
|
||||
// TODO: handle video/GIF/Audio data
|
||||
let imageData = CurrentValueSubject<Data?, Never>(nil)
|
||||
let thumbnailImage = CurrentValueSubject<UIImage?, Never>(nil)
|
||||
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
|
||||
let description = CurrentValueSubject<String?, Never>(nil)
|
||||
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||
|
||||
private(set) lazy var uploadStateMachine: GKStateMachine = {
|
||||
|
@ -50,15 +52,24 @@ final class MastodonAttachmentService {
|
|||
init(
|
||||
context: AppContext,
|
||||
pickerResult: PHPickerResult,
|
||||
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
) {
|
||||
self.context = context
|
||||
self.authenticationBox = initalAuthenticationBox
|
||||
self.authenticationBox = initialAuthenticationBox
|
||||
// end init
|
||||
|
||||
setupServiceObserver()
|
||||
|
||||
PHPickerResultLoader.loadImageData(from: pickerResult)
|
||||
Just(pickerResult)
|
||||
.flatMap { result -> AnyPublisher<Mastodon.Query.MediaAttachment?, Error> in
|
||||
if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) {
|
||||
return PHPickerResultLoader.loadImageData(from: result).eraseToAnyPublisher()
|
||||
}
|
||||
if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) {
|
||||
return PHPickerResultLoader.loadVideoData(from: result).eraseToAnyPublisher()
|
||||
}
|
||||
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
|
||||
}
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
|
@ -68,9 +79,9 @@ final class MastodonAttachmentService {
|
|||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] imageData in
|
||||
} receiveValue: { [weak self] file in
|
||||
guard let self = self else { return }
|
||||
self.imageData.value = imageData
|
||||
self.file.value = file
|
||||
self.uploadStateMachine.enter(UploadState.Initial.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
@ -79,30 +90,49 @@ final class MastodonAttachmentService {
|
|||
init(
|
||||
context: AppContext,
|
||||
image: UIImage,
|
||||
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
) {
|
||||
self.context = context
|
||||
self.authenticationBox = initalAuthenticationBox
|
||||
self.authenticationBox = initialAuthenticationBox
|
||||
// end init
|
||||
|
||||
setupServiceObserver()
|
||||
|
||||
imageData.value = image.jpegData(compressionQuality: 0.75)
|
||||
file.value = .jpeg(image.jpegData(compressionQuality: 0.75))
|
||||
uploadStateMachine.enter(UploadState.Initial.self)
|
||||
}
|
||||
|
||||
init(
|
||||
context: AppContext,
|
||||
imageData: Data,
|
||||
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
documentURL: URL,
|
||||
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||
) {
|
||||
self.context = context
|
||||
self.authenticationBox = initalAuthenticationBox
|
||||
self.authenticationBox = initialAuthenticationBox
|
||||
// end init
|
||||
|
||||
setupServiceObserver()
|
||||
|
||||
self.imageData.value = imageData
|
||||
Just(documentURL)
|
||||
.flatMap { documentURL -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> in
|
||||
return MastodonAttachmentService.loadAttachment(url: documentURL)
|
||||
}
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
self.error.value = error
|
||||
self.uploadStateMachine.enter(UploadState.Fail.self)
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] file in
|
||||
guard let self = self else { return }
|
||||
self.file.value = file
|
||||
self.uploadStateMachine.enter(UploadState.Initial.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
uploadStateMachine.enter(UploadState.Initial.self)
|
||||
}
|
||||
|
||||
|
@ -113,6 +143,49 @@ final class MastodonAttachmentService {
|
|||
self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
file
|
||||
.map { file -> UIImage? in
|
||||
guard let file = file else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch file {
|
||||
case .jpeg(let data), .png(let data):
|
||||
return data.flatMap { UIImage(data: $0) }
|
||||
case .gif:
|
||||
// TODO:
|
||||
return nil
|
||||
case .other(let url, _, _):
|
||||
guard let url = url, FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let asset = AVURLAsset(url: url)
|
||||
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
|
||||
do {
|
||||
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
|
||||
let image = UIImage(cgImage: cgImage)
|
||||
return image
|
||||
} catch {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: thumbnail generate fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.assign(to: \.value, on: thumbnailImage)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonAttachmentService {
|
||||
enum AttachmentError: Error {
|
||||
case invalidAttachmentType
|
||||
case attachmentTooLarge
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -136,3 +209,64 @@ extension MastodonAttachmentService: Equatable, Hashable {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonAttachmentService {
|
||||
|
||||
private static func createWorkingQueue() -> DispatchQueue {
|
||||
return DispatchQueue(label: "org.joinmastodon.app.MastodonAttachmentService.\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
static func loadAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
|
||||
guard let uti = UTType(filenameExtension: url.pathExtension) else {
|
||||
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
if uti.conforms(to: .image) {
|
||||
return loadImageAttachment(url: url)
|
||||
} else if uti.conforms(to: .movie) {
|
||||
return loadVideoAttachment(url: url)
|
||||
} else {
|
||||
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
static func loadImageAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
|
||||
Future<Mastodon.Query.MediaAttachment, Error> { promise in
|
||||
createWorkingQueue().async {
|
||||
do {
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
let imageData = try Data(contentsOf: url)
|
||||
promise(.success(.jpeg(imageData)))
|
||||
} catch {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
static func loadVideoAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
|
||||
Future<Mastodon.Query.MediaAttachment, Error> { promise in
|
||||
createWorkingQueue().async {
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
let fileName = UUID().uuidString
|
||||
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.copyItem(at: url, to: fileURL)
|
||||
let file = Mastodon.Query.MediaAttachment.other(fileURL, fileExtension: fileURL.pathExtension, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
|
||||
promise(.success(file))
|
||||
} catch {
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ final class NotificationService {
|
|||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue")
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.NotificationService.working-queue")
|
||||
|
||||
// input
|
||||
weak var apiService: APIService?
|
||||
|
|
|
@ -16,7 +16,7 @@ final class StatusPrefetchingService {
|
|||
|
||||
typealias TaskID = String
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPrefetchingService.working-queue")
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.StatusPrefetchingService.working-queue")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:]
|
||||
|
|
|
@ -16,7 +16,7 @@ final class StatusPublishService {
|
|||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPublishService.working-queue")
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.StatusPublishService.working-queue")
|
||||
|
||||
// input
|
||||
var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models
|
||||
|
|
|
@ -14,7 +14,7 @@ import os.log
|
|||
final class VideoPlaybackService {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.VideoPlaybackService.working-queue")
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.VideoPlaybackService.working-queue")
|
||||
private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:]
|
||||
|
||||
// only for video kind
|
||||
|
|
|
@ -10,6 +10,8 @@ import UIKit
|
|||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import AlamofireImage
|
||||
import Kingfisher
|
||||
|
||||
class AppContext: ObservableObject {
|
||||
|
||||
|
@ -99,3 +101,107 @@ class AppContext: ObservableObject {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension AppContext {
|
||||
|
||||
typealias ByteCount = Int
|
||||
|
||||
static let byteCountFormatter: ByteCountFormatter = {
|
||||
let formatter = ByteCountFormatter()
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let purgeCacheWorkingQueue = DispatchQueue(label: "org.joinmastodon.app.AppContext.purgeCacheWorkingQueue")
|
||||
|
||||
func purgeCache() -> AnyPublisher<ByteCount, Never> {
|
||||
Publishers.MergeMany([
|
||||
AppContext.purgeAlamofireImageCache(),
|
||||
AppContext.purgeKingfisherCache(),
|
||||
AppContext.purgeTemporaryDirectory(),
|
||||
])
|
||||
.reduce(0, +)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private static func purgeAlamofireImageCache() -> AnyPublisher<ByteCount, Never> {
|
||||
Future<ByteCount, Never> { promise in
|
||||
AppContext.purgeCacheWorkingQueue.async {
|
||||
// clean image cache for AlamofireImage
|
||||
let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
ImageDownloader.defaultURLCache().removeAllCachedResponses()
|
||||
let currentDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
let purgedDiskBytes = max(0, diskBytes - currentDiskBytes)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: purge AlamofireImage cache bytes: %ld -> %ld (%ld)", ((#file as NSString).lastPathComponent), #line, #function, diskBytes, currentDiskBytes, purgedDiskBytes)
|
||||
promise(.success(purgedDiskBytes))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private static func purgeKingfisherCache() -> AnyPublisher<ByteCount, Never> {
|
||||
Future<ByteCount, Never> { promise in
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||
switch result {
|
||||
case .success(let diskBytes):
|
||||
KingfisherManager.shared.cache.clearCache()
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { currentResult in
|
||||
switch currentResult {
|
||||
case .success(let currentDiskBytes):
|
||||
let purgedDiskBytes = max(0, Int(diskBytes) - Int(currentDiskBytes))
|
||||
promise(.success(purgedDiskBytes))
|
||||
case .failure:
|
||||
promise(.success(0))
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
promise(.success(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private static func purgeTemporaryDirectory() -> AnyPublisher<ByteCount, Never> {
|
||||
Future<ByteCount, Never> { promise in
|
||||
AppContext.purgeCacheWorkingQueue.async {
|
||||
let fileManager = FileManager.default
|
||||
let temporaryDirectoryURL = fileManager.temporaryDirectory
|
||||
|
||||
let resourceKeys = Set<URLResourceKey>([.fileSizeKey, .isDirectoryKey])
|
||||
guard let directoryEnumerator = fileManager.enumerator(
|
||||
at: temporaryDirectoryURL,
|
||||
includingPropertiesForKeys: Array(resourceKeys),
|
||||
options: .skipsHiddenFiles
|
||||
) else {
|
||||
promise(.success(0))
|
||||
return
|
||||
}
|
||||
|
||||
var fileURLs: [URL] = []
|
||||
var totalFileSizeInBytes = 0
|
||||
for case let fileURL as URL in directoryEnumerator {
|
||||
guard let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys),
|
||||
let isDirectory = resourceValues.isDirectory else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard !isDirectory else {
|
||||
continue
|
||||
}
|
||||
fileURLs.append(fileURL)
|
||||
totalFileSizeInBytes += resourceValues.fileSize ?? 0
|
||||
}
|
||||
|
||||
for fileURL in fileURLs {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
promise(.success(totalFileSizeInBytes))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
//
|
||||
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: purge temporary directory success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
// }
|
||||
}
|
||||
|
|
|
@ -10,12 +10,13 @@ import Foundation
|
|||
import Combine
|
||||
import MobileCoreServices
|
||||
import PhotosUI
|
||||
import MastodonSDK
|
||||
|
||||
// load image with low memory usage
|
||||
// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/
|
||||
enum PHPickerResultLoader {
|
||||
|
||||
static func loadImageData(from result: PHPickerResult) -> Future<Data?, Error> {
|
||||
static func loadImageData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
|
||||
Future { promise in
|
||||
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
|
||||
if let error = error {
|
||||
|
@ -64,7 +65,36 @@ enum PHPickerResultLoader {
|
|||
let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load image %s", ((#file as NSString).lastPathComponent), #line, #function, dataSize)
|
||||
|
||||
promise(.success(data as Data))
|
||||
let file = Mastodon.Query.MediaAttachment.jpeg(data as Data)
|
||||
promise(.success(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func loadVideoData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
|
||||
Future { promise in
|
||||
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||
if let error = error {
|
||||
promise(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = url else {
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
|
||||
let fileName = UUID().uuidString
|
||||
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.copyItem(at: url, to: fileURL)
|
||||
let file = Mastodon.Query.MediaAttachment.other(fileURL, fileExtension: fileURL.pathExtension, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
|
||||
promise(.success(file))
|
||||
} catch {
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -217,10 +217,14 @@ extension Mastodon.API.Account {
|
|||
source.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
|
||||
source.language.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
|
||||
}
|
||||
fieldsAttributes.flatMap { fieldsAttributes in
|
||||
for fieldsAttribute in fieldsAttributes {
|
||||
data.append(Data.multipart(key: "fields_attributes[name][]", value: fieldsAttribute.name))
|
||||
data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value))
|
||||
if let fieldsAttributes = fieldsAttributes {
|
||||
if fieldsAttributes.isEmpty {
|
||||
data.append(Data.multipart(key: "fields_attributes[]", value: ""))
|
||||
} else {
|
||||
for (i, fieldsAttribute) in fieldsAttributes.enumerated() {
|
||||
data.append(Data.multipart(key: "fields_attributes[\(i)][name]", value: fieldsAttribute.name))
|
||||
data.append(Data.multipart(key: "fields_attributes[\(i)][value]", value: fieldsAttribute.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -103,7 +103,7 @@ extension Mastodon.API.App {
|
|||
|
||||
public init(
|
||||
clientName: String,
|
||||
redirectURIs: String = "urn:ietf:wg:oauth:2.0:oob",
|
||||
redirectURIs: String,
|
||||
scopes: String? = "read write follow push",
|
||||
website: String?
|
||||
) {
|
||||
|
|
|
@ -42,11 +42,17 @@ extension Mastodon.API.Media {
|
|||
authorization: authorization
|
||||
)
|
||||
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
|
||||
let serialStream = query.serialStream
|
||||
request.httpBodyStream = serialStream.boundStreams.input
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.handleEvents(receiveCancel: {
|
||||
// retain and handle cancel task
|
||||
serialStream.boundStreams.output.close()
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
@ -73,15 +79,30 @@ extension Mastodon.API.Media {
|
|||
}
|
||||
|
||||
var body: Data? {
|
||||
var data = Data()
|
||||
|
||||
file.flatMap { data.append(Data.multipart(key: "file", value: $0)) }
|
||||
thumbnail.flatMap { data.append(Data.multipart(key: "thumbnail", value: $0)) }
|
||||
description.flatMap { data.append(Data.multipart(key: "description", value: $0)) }
|
||||
focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) }
|
||||
// using stream data
|
||||
return nil
|
||||
}
|
||||
|
||||
var serialStream: SerialStream {
|
||||
var streams: [InputStream] = []
|
||||
|
||||
data.append(Data.multipartEnd())
|
||||
return data
|
||||
file.flatMap { value in
|
||||
streams.append(InputStream(data: Data.multipart(key: "file", value: value)))
|
||||
value.multipartStreamValue.flatMap { streams.append($0) }
|
||||
}
|
||||
thumbnail.flatMap { value in
|
||||
streams.append(InputStream(data: Data.multipart(key: "thumbnail", value: value)))
|
||||
value.multipartStreamValue.flatMap { streams.append($0) }
|
||||
}
|
||||
description.flatMap { value in
|
||||
streams.append(InputStream(data: Data.multipart(key: "description", value: value)))
|
||||
}
|
||||
focus.flatMap { value in
|
||||
streams.append(InputStream(data: Data.multipart(key: "focus", value: value)))
|
||||
}
|
||||
streams.append(InputStream(data: Data.multipartEnd()))
|
||||
|
||||
return SerialStream(streams: streams)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,8 +150,45 @@ extension Mastodon.API.Media {
|
|||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public typealias UpdateMediaQuery = UploadMeidaQuery
|
||||
|
||||
public struct UpdateMediaQuery: PutQuery {
|
||||
|
||||
public let file: Mastodon.Query.MediaAttachment?
|
||||
public let thumbnail: Mastodon.Query.MediaAttachment?
|
||||
public let description: String?
|
||||
public let focus: String?
|
||||
|
||||
public init(
|
||||
file: Mastodon.Query.MediaAttachment?,
|
||||
thumbnail: Mastodon.Query.MediaAttachment?,
|
||||
description: String?,
|
||||
focus: String?
|
||||
) {
|
||||
self.file = file
|
||||
self.thumbnail = thumbnail
|
||||
self.description = description
|
||||
self.focus = focus
|
||||
}
|
||||
|
||||
var contentType: String? {
|
||||
return Self.multipartContentType()
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem]? {
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: Data? {
|
||||
var data = Data()
|
||||
|
||||
// not modify uploaded binary data
|
||||
description.flatMap { data.append(Data.multipart(key: "description", value: $0)) }
|
||||
focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) }
|
||||
|
||||
data.append(Data.multipartEnd())
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -139,7 +139,7 @@ extension Mastodon.API.OAuth {
|
|||
forceLogin: String? = nil,
|
||||
responseType: String = "code",
|
||||
clientID: String,
|
||||
redirectURI: String = "urn:ietf:wg:oauth:2.0:oob",
|
||||
redirectURI: String,
|
||||
scope: String? = "read write follow push"
|
||||
) {
|
||||
self.forceLogin = forceLogin
|
||||
|
@ -166,7 +166,7 @@ extension Mastodon.API.OAuth {
|
|||
public init(
|
||||
clientID: String,
|
||||
clientSecret: String,
|
||||
redirectURI: String = "urn:ietf:wg:oauth:2.0:oob",
|
||||
redirectURI: String,
|
||||
scope: String? = "read write follow push",
|
||||
code: String?,
|
||||
grantType: String
|
||||
|
|
|
@ -27,5 +27,11 @@ extension Mastodon.Entity {
|
|||
case value
|
||||
case verifiedAt = "verified_at"
|
||||
}
|
||||
|
||||
public init(name: String, value: String, verifiedAt: Date? = nil) {
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.verifiedAt = verifiedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,12 @@ extension Data {
|
|||
data.append("Content-Type: \(contentType)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
data.append("\r\n".data(using: .utf8)!)
|
||||
data.append(value.multipartValue)
|
||||
if value.multipartStreamValue == nil {
|
||||
data.append(value.multipartValue)
|
||||
} else {
|
||||
// needs append stream multipart value outside
|
||||
// seealso: SerialStream
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
|
@ -16,21 +16,22 @@ extension Mastodon.Query {
|
|||
/// PNG (Portable Network Graphics) image
|
||||
case png(Data?)
|
||||
/// Other media file
|
||||
case other(Data?, fileExtension: String, mimeType: String)
|
||||
/// e.g video
|
||||
case other(URL?, fileExtension: String, mimeType: String)
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Query.MediaAttachment {
|
||||
var data: Data? {
|
||||
public var data: Data? {
|
||||
switch self {
|
||||
case .jpeg(let data): return data
|
||||
case .gif(let data): return data
|
||||
case .png(let data): return data
|
||||
case .other(let data, _, _): return data
|
||||
case .other: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var fileName: String {
|
||||
public var fileName: String {
|
||||
let name = UUID().uuidString
|
||||
switch self {
|
||||
case .jpeg: return "\(name).jpg"
|
||||
|
@ -40,7 +41,7 @@ extension Mastodon.Query.MediaAttachment {
|
|||
}
|
||||
}
|
||||
|
||||
var mimeType: String {
|
||||
public var mimeType: String {
|
||||
switch self {
|
||||
case .jpeg: return "image/jpg"
|
||||
case .gif: return "image/gif"
|
||||
|
@ -56,6 +57,14 @@ extension Mastodon.Query.MediaAttachment {
|
|||
|
||||
extension Mastodon.Query.MediaAttachment: MultipartFormValue {
|
||||
var multipartValue: Data { return data ?? Data() }
|
||||
var multipartStreamValue: InputStream? {
|
||||
switch self {
|
||||
case .other(let url, _, _):
|
||||
return url.flatMap { InputStream(url: $0) }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var multipartContentType: String? { return mimeType }
|
||||
var multipartFilename: String? { return fileName }
|
||||
}
|
||||
|
|
|
@ -13,10 +13,15 @@ enum Multipart {
|
|||
|
||||
protocol MultipartFormValue {
|
||||
var multipartValue: Data { get }
|
||||
var multipartStreamValue: InputStream? { get }
|
||||
var multipartContentType: String? { get }
|
||||
var multipartFilename: String? { get }
|
||||
}
|
||||
|
||||
extension MultipartFormValue {
|
||||
var multipartStreamValue: InputStream? { nil }
|
||||
}
|
||||
|
||||
extension Bool: MultipartFormValue {
|
||||
var multipartValue: Data {
|
||||
switch self {
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
//
|
||||
// SerialStream.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// ref:
|
||||
// - https://developer.apple.com/documentation/foundation/url_loading_system/uploading_streams_of_data#3037342
|
||||
// - https://forums.swift.org/t/extension-write-to-outputstream/42817/4
|
||||
// - https://gist.github.com/khanlou/b5e07f963bedcb6e0fcc5387b46991c3
|
||||
|
||||
final class SerialStream: NSObject {
|
||||
var writingTimerSubscriber: AnyCancellable?
|
||||
|
||||
// serial stream source
|
||||
private var streams: [InputStream]
|
||||
private var currentStreamIndex = 0
|
||||
|
||||
private static let bufferSize = 5 * 1024 * 1024 // 5MiB
|
||||
|
||||
private var buffer: UnsafeMutablePointer<UInt8>
|
||||
private var canWrite = false
|
||||
|
||||
private let workingQueue = DispatchQueue(label: "org.joinmastodon.app.SerialStream.\(UUID().uuidString)")
|
||||
|
||||
// bound pair stream
|
||||
private(set) lazy var boundStreams: Streams = {
|
||||
var inputStream: InputStream?
|
||||
var outputStream: OutputStream?
|
||||
Stream.getBoundStreams(withBufferSize: SerialStream.bufferSize, inputStream: &inputStream, outputStream: &outputStream)
|
||||
guard let input = inputStream, let output = outputStream else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
output.delegate = self
|
||||
output.schedule(in: .current, forMode: .default)
|
||||
output.open()
|
||||
|
||||
return Streams(input: input, output: output)
|
||||
}()
|
||||
|
||||
init(streams: [InputStream]) {
|
||||
self.streams = streams
|
||||
self.buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: SerialStream.bufferSize)
|
||||
self.buffer.initialize(repeating: 0, count: SerialStream.bufferSize)
|
||||
super.init()
|
||||
|
||||
// Stream worker
|
||||
writingTimerSubscriber = Timer.publish(every: 0.5, on: .current, in: .default)
|
||||
.autoconnect()
|
||||
.receive(on: workingQueue)
|
||||
.sink { [weak self] timer in
|
||||
guard let self = self else { return }
|
||||
guard self.canWrite else { return }
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: writing…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
guard self.currentStreamIndex < self.streams.count else {
|
||||
self.boundStreams.output.close()
|
||||
self.writingTimerSubscriber = nil // cancel timer after task completed
|
||||
return
|
||||
}
|
||||
|
||||
var readBytesCount = 0
|
||||
defer {
|
||||
var baseAddress = 0
|
||||
var remainsBytes = readBytesCount
|
||||
while remainsBytes > 0 {
|
||||
let result = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes)
|
||||
baseAddress += result
|
||||
remainsBytes -= result
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: write %ld/%ld bytes. write result: %ld", ((#file as NSString).lastPathComponent), #line, #function, baseAddress, readBytesCount, result)
|
||||
}
|
||||
}
|
||||
|
||||
while readBytesCount < SerialStream.bufferSize {
|
||||
// close when no more source streams
|
||||
guard self.currentStreamIndex < self.streams.count else {
|
||||
break
|
||||
}
|
||||
|
||||
let inputStream = self.streams[self.currentStreamIndex]
|
||||
// open input if needs
|
||||
if inputStream.streamStatus != .open {
|
||||
inputStream.open()
|
||||
}
|
||||
// read next source stream when current drain
|
||||
guard inputStream.hasBytesAvailable else {
|
||||
self.currentStreamIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let reaminsCount = SerialStream.bufferSize - readBytesCount
|
||||
let readCount = inputStream.read(&self.buffer[readBytesCount], maxLength: reaminsCount)
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: read source %ld bytes", ((#file as NSString).lastPathComponent), #line, #function, readCount)
|
||||
|
||||
switch readCount {
|
||||
case 0:
|
||||
self.currentStreamIndex += 1
|
||||
continue
|
||||
case -1:
|
||||
self.boundStreams.output.close()
|
||||
return
|
||||
default:
|
||||
self.canWrite = false
|
||||
readBytesCount += readCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SerialStream {
|
||||
struct Streams {
|
||||
let input: InputStream
|
||||
let output: OutputStream
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StreamDelegate
|
||||
extension SerialStream: StreamDelegate {
|
||||
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: eventCode %s", ((#file as NSString).lastPathComponent), #line, #function, String(eventCode.rawValue))
|
||||
|
||||
guard aStream == boundStreams.output else {
|
||||
return
|
||||
}
|
||||
|
||||
if eventCode.contains(.hasSpaceAvailable) {
|
||||
canWrite = true
|
||||
}
|
||||
|
||||
if eventCode.contains(.errorOccurred) {
|
||||
// Close the streams and alert the user that the upload failed.
|
||||
boundStreams.output.close()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.org.joinmastodon.mastodon-temp</string>
|
||||
<string>group.org.joinmastodon.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
17
Podfile
17
Podfile
|
@ -13,6 +13,9 @@ target 'Mastodon' do
|
|||
pod 'SwiftGen', '~> 6.4.0'
|
||||
pod 'DateToolsSwift', '~> 5.0.0'
|
||||
pod 'Kanna', '~> 5.2.2'
|
||||
|
||||
# DEBUG
|
||||
pod 'FLEX', '~> 4.4.0', :configurations => ['Debug']
|
||||
|
||||
target 'MastodonTests' do
|
||||
inherit! :search_paths
|
||||
|
@ -23,14 +26,16 @@ target 'Mastodon' do
|
|||
# Pods for testing
|
||||
end
|
||||
|
||||
target 'NotificationService' do
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
target 'AppShared' do
|
||||
|
||||
end
|
||||
target 'NotificationService' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
end
|
||||
|
||||
target 'AppShared' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
end
|
||||
|
||||
plugin 'cocoapods-keys', {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
PODS:
|
||||
- DateToolsSwift (5.0.0)
|
||||
- FLEX (4.4.1)
|
||||
- Kanna (5.2.4)
|
||||
- Keys (1.0.1)
|
||||
- SwiftGen (6.4.0)
|
||||
|
@ -7,6 +8,7 @@ PODS:
|
|||
|
||||
DEPENDENCIES:
|
||||
- DateToolsSwift (~> 5.0.0)
|
||||
- FLEX (~> 4.4.0)
|
||||
- Kanna (~> 5.2.2)
|
||||
- Keys (from `Pods/CocoaPodsKeys`)
|
||||
- SwiftGen (~> 6.4.0)
|
||||
|
@ -15,6 +17,7 @@ DEPENDENCIES:
|
|||
SPEC REPOS:
|
||||
trunk:
|
||||
- DateToolsSwift
|
||||
- FLEX
|
||||
- Kanna
|
||||
- SwiftGen
|
||||
- "UITextField+Shake"
|
||||
|
@ -25,11 +28,12 @@ EXTERNAL SOURCES:
|
|||
|
||||
SPEC CHECKSUMS:
|
||||
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
|
||||
FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab
|
||||
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
|
||||
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
|
||||
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
|
||||
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
|
||||
|
||||
PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287
|
||||
PODFILE CHECKSUM: 0daad1778e56099e7a7e7ebe3d292d20051840fa
|
||||
|
||||
COCOAPODS: 1.10.1
|
||||
|
|
Loading…
Reference in New Issue