Merge branch 'release/0.6.0'
This commit is contained in:
commit
b190851056
|
@ -8,5 +8,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum AppName {
|
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 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 notificationPrivateKeyName = "notification-private-key-base64"
|
||||||
static let notificationAuthName = "notification-auth-base64"
|
static let notificationAuthName = "notification-auth-base64"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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">
|
<entity name="Application" representedClassName=".Application" syncable="YES">
|
||||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="name" attributeType="String"/>
|
<attribute name="name" attributeType="String"/>
|
||||||
|
@ -102,6 +102,7 @@
|
||||||
<attribute name="displayName" attributeType="String"/>
|
<attribute name="displayName" attributeType="String"/>
|
||||||
<attribute name="domain" attributeType="String"/>
|
<attribute name="domain" attributeType="String"/>
|
||||||
<attribute name="emojisData" optional="YES" attributeType="Binary"/>
|
<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="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="header" attributeType="String"/>
|
<attribute name="header" attributeType="String"/>
|
||||||
|
@ -273,7 +274,7 @@
|
||||||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
||||||
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
|
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
|
||||||
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
|
<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="Mention" positionX="0" positionY="0" width="128" height="149"/>
|
||||||
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
|
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
|
||||||
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
|
<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 url: String?
|
||||||
|
|
||||||
@NSManaged public private(set) var emojisData: Data?
|
@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 statusesCount: NSNumber
|
||||||
@NSManaged public private(set) var followingCount: NSNumber
|
@NSManaged public private(set) var followingCount: NSNumber
|
||||||
|
@ -92,6 +93,7 @@ extension MastodonUser {
|
||||||
user.note = property.note
|
user.note = property.note
|
||||||
user.url = property.url
|
user.url = property.url
|
||||||
user.emojisData = property.emojisData
|
user.emojisData = property.emojisData
|
||||||
|
user.fieldsData = property.fieldsData
|
||||||
|
|
||||||
user.statusesCount = NSNumber(value: property.statusesCount)
|
user.statusesCount = NSNumber(value: property.statusesCount)
|
||||||
user.followingCount = NSNumber(value: property.followingCount)
|
user.followingCount = NSNumber(value: property.followingCount)
|
||||||
|
@ -161,6 +163,11 @@ extension MastodonUser {
|
||||||
self.emojisData = emojisData
|
self.emojisData = emojisData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public func update(fieldsData: Data?) {
|
||||||
|
if self.fieldsData != fieldsData {
|
||||||
|
self.fieldsData = fieldsData
|
||||||
|
}
|
||||||
|
}
|
||||||
public func update(statusesCount: Int) {
|
public func update(statusesCount: Int) {
|
||||||
if self.statusesCount.intValue != statusesCount {
|
if self.statusesCount.intValue != statusesCount {
|
||||||
self.statusesCount = NSNumber(value: statusesCount)
|
self.statusesCount = NSNumber(value: statusesCount)
|
||||||
|
@ -281,6 +288,7 @@ extension MastodonUser {
|
||||||
public let note: String?
|
public let note: String?
|
||||||
public let url: String?
|
public let url: String?
|
||||||
public let emojisData: Data?
|
public let emojisData: Data?
|
||||||
|
public let fieldsData: Data?
|
||||||
public let statusesCount: Int
|
public let statusesCount: Int
|
||||||
public let followingCount: Int
|
public let followingCount: Int
|
||||||
public let followersCount: Int
|
public let followersCount: Int
|
||||||
|
@ -304,6 +312,7 @@ extension MastodonUser {
|
||||||
note: String?,
|
note: String?,
|
||||||
url: String?,
|
url: String?,
|
||||||
emojisData: Data?,
|
emojisData: Data?,
|
||||||
|
fieldsData: Data?,
|
||||||
statusesCount: Int,
|
statusesCount: Int,
|
||||||
followingCount: Int,
|
followingCount: Int,
|
||||||
followersCount: Int,
|
followersCount: Int,
|
||||||
|
@ -326,6 +335,7 @@ extension MastodonUser {
|
||||||
self.note = note
|
self.note = note
|
||||||
self.url = url
|
self.url = url
|
||||||
self.emojisData = emojisData
|
self.emojisData = emojisData
|
||||||
|
self.fieldsData = fieldsData
|
||||||
self.statusesCount = statusesCount
|
self.statusesCount = statusesCount
|
||||||
self.followingCount = followingCount
|
self.followingCount = followingCount
|
||||||
self.followersCount = followersCount
|
self.followersCount = followersCount
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
|
|
||||||
public protocol Managed: AnyObject, NSFetchRequestResult {
|
public protocol Managed: NSFetchRequestResult {
|
||||||
static var entityName: String { get }
|
static var entityName: String { get }
|
||||||
static var defaultSortDescriptors: [NSSortDescriptor] { get }
|
static var defaultSortDescriptors: [NSSortDescriptor] { get }
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,11 @@
|
||||||
},
|
},
|
||||||
"publish_post_failure": {
|
"publish_post_failure": {
|
||||||
"title": "Publish 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": {
|
"sign_out": {
|
||||||
"title": "Sign out",
|
"title": "Sign out",
|
||||||
|
@ -39,6 +43,10 @@
|
||||||
"delete_post": {
|
"delete_post": {
|
||||||
"title": "Are you sure you want to delete this post?",
|
"title": "Are you sure you want to delete this post?",
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"clean_cache": {
|
||||||
|
"title": "Clean Cache",
|
||||||
|
"message": "Successfully clean %s cache."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
|
@ -165,6 +173,9 @@
|
||||||
"edit_info": "Edit info"
|
"edit_info": "Edit info"
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
|
"timestamp": {
|
||||||
|
"now": "Now"
|
||||||
|
},
|
||||||
"loader": {
|
"loader": {
|
||||||
"load_missing_posts": "Load missing posts",
|
"load_missing_posts": "Load missing posts",
|
||||||
"loading_missing_posts": "Loading missing posts...",
|
"loading_missing_posts": "Loading missing posts...",
|
||||||
|
@ -404,6 +415,13 @@
|
||||||
"count_followers": "%ld followers"
|
"count_followers": "%ld followers"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fields": {
|
||||||
|
"add_row": "Add Row",
|
||||||
|
"placeholder": {
|
||||||
|
"label": "Label",
|
||||||
|
"content": "Content"
|
||||||
|
}
|
||||||
|
},
|
||||||
"segmented_control": {
|
"segmented_control": {
|
||||||
"posts": "Posts",
|
"posts": "Posts",
|
||||||
"replies": "Replies",
|
"replies": "Replies",
|
||||||
|
|
|
@ -178,15 +178,12 @@
|
||||||
5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; };
|
||||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
|
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 */; };
|
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; };
|
||||||
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; };
|
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; };
|
||||||
DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.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 */; };
|
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 */; };
|
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */; };
|
||||||
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; };
|
||||||
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; };
|
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; };
|
||||||
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.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 */; };
|
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
|
||||||
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.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 */; };
|
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, ); }; };
|
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 */; };
|
DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; };
|
||||||
DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
@ -696,10 +703,10 @@
|
||||||
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
@ -1068,7 +1086,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
DB6805102637D0F800430867 /* KeychainAccess in Frameworks */,
|
DB6805102637D0F800430867 /* KeychainAccess in Frameworks */,
|
||||||
D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */,
|
EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -1096,7 +1114,7 @@
|
||||||
DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */,
|
DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */,
|
||||||
DBF8AE862632992800C9C23C /* Base85 in Frameworks */,
|
DBF8AE862632992800C9C23C /* Base85 in Frameworks */,
|
||||||
DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */,
|
DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */,
|
||||||
7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */,
|
B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -1183,6 +1201,10 @@
|
||||||
B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */,
|
B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */,
|
||||||
D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */,
|
D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */,
|
||||||
B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.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;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1402,6 +1424,7 @@
|
||||||
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
|
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
|
||||||
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
|
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
|
||||||
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
|
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
|
||||||
|
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */,
|
||||||
);
|
);
|
||||||
path = Section;
|
path = Section;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1463,6 +1486,7 @@
|
||||||
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
|
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
|
||||||
DB6D9F8326358EEC008423CD /* SettingsItem.swift */,
|
DB6D9F8326358EEC008423CD /* SettingsItem.swift */,
|
||||||
DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */,
|
DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */,
|
||||||
|
DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */,
|
||||||
);
|
);
|
||||||
path = Item;
|
path = Item;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1526,8 +1550,8 @@
|
||||||
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */,
|
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */,
|
||||||
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */,
|
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */,
|
||||||
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */,
|
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */,
|
||||||
79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */,
|
F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */,
|
||||||
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */,
|
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1590,7 +1614,6 @@
|
||||||
DB68A03825E900CC00CFDF14 /* Share */,
|
DB68A03825E900CC00CFDF14 /* Share */,
|
||||||
0FAA0FDD25E0B5700017CCDE /* Welcome */,
|
0FAA0FDD25E0B5700017CCDE /* Welcome */,
|
||||||
0FAA102525E1125D0017CCDE /* PickServer */,
|
0FAA102525E1125D0017CCDE /* PickServer */,
|
||||||
DB0140A625C40C0900F9F3CF /* PinBasedAuthentication */,
|
|
||||||
DBE0821A25CD382900FD6BBD /* Register */,
|
DBE0821A25CD382900FD6BBD /* Register */,
|
||||||
DB72602125E36A2500235243 /* ServerRules */,
|
DB72602125E36A2500235243 /* ServerRules */,
|
||||||
2D364F7025E66D5B00204FDC /* ResendEmail */,
|
2D364F7025E66D5B00204FDC /* ResendEmail */,
|
||||||
|
@ -1599,16 +1622,6 @@
|
||||||
path = Onboarding;
|
path = Onboarding;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
DB0140A625C40C0900F9F3CF /* PinBasedAuthentication */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */,
|
|
||||||
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */,
|
|
||||||
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */,
|
|
||||||
);
|
|
||||||
path = PinBasedAuthentication;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
DB084B5125CBC56300F898ED /* CoreDataStack */ = {
|
DB084B5125CBC56300F898ED /* CoreDataStack */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1619,6 +1632,7 @@
|
||||||
DB6D9F4826353FD6008423CD /* Subscription.swift */,
|
DB6D9F4826353FD6008423CD /* Subscription.swift */,
|
||||||
DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */,
|
DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */,
|
||||||
DBAFB7342645463500371D5F /* Emojis.swift */,
|
DBAFB7342645463500371D5F /* Emojis.swift */,
|
||||||
|
DBA94439265CC0FC00C537E1 /* Fields.swift */,
|
||||||
);
|
);
|
||||||
path = CoreDataStack;
|
path = CoreDataStack;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1892,6 +1906,7 @@
|
||||||
children = (
|
children = (
|
||||||
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */,
|
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */,
|
||||||
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */,
|
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */,
|
||||||
|
DB029E94266A20430062874E /* MastodonAuthenticationController.swift */,
|
||||||
);
|
);
|
||||||
path = Share;
|
path = Share;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1913,6 +1928,7 @@
|
||||||
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */,
|
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */,
|
||||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
|
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
|
||||||
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */,
|
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */,
|
||||||
|
DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */,
|
||||||
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */,
|
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */,
|
||||||
);
|
);
|
||||||
path = MastodonSDK;
|
path = MastodonSDK;
|
||||||
|
@ -2139,6 +2155,7 @@
|
||||||
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
||||||
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
||||||
DB6D1B23263684C600ACB481 /* UserDefaults.swift */,
|
DB6D1B23263684C600ACB481 /* UserDefaults.swift */,
|
||||||
|
DB97131E2666078B00BD1E90 /* Date.swift */,
|
||||||
);
|
);
|
||||||
path = Extension;
|
path = Extension;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2345,6 +2362,7 @@
|
||||||
DBB525732612D5A5002F1F29 /* View */,
|
DBB525732612D5A5002F1F29 /* View */,
|
||||||
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
|
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
|
||||||
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */,
|
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */,
|
||||||
|
DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */,
|
||||||
);
|
);
|
||||||
path = Header;
|
path = Header;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2357,6 +2375,9 @@
|
||||||
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
|
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
|
||||||
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
|
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
|
||||||
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
|
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
|
||||||
|
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */,
|
||||||
|
DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */,
|
||||||
|
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */,
|
||||||
);
|
);
|
||||||
path = View;
|
path = View;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2756,7 +2777,7 @@
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"$(DERIVED_FILE_DIR)/Pods-Mastodon-NotificationService-checkManifestLockResult.txt",
|
"$(DERIVED_FILE_DIR)/Pods-NotificationService-checkManifestLockResult.txt",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
|
@ -2839,7 +2860,7 @@
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"$(DERIVED_FILE_DIR)/Pods-Mastodon-AppShared-checkManifestLockResult.txt",
|
"$(DERIVED_FILE_DIR)/Pods-AppShared-checkManifestLockResult.txt",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
|
@ -2921,6 +2942,7 @@
|
||||||
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
|
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
|
||||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
|
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
|
||||||
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
|
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
|
||||||
|
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */,
|
||||||
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
|
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
|
||||||
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
|
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
|
||||||
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
|
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
|
||||||
|
@ -2992,6 +3014,7 @@
|
||||||
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
|
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
|
||||||
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
|
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
|
||||||
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
||||||
|
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */,
|
||||||
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
|
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
|
||||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
||||||
|
@ -3006,17 +3029,20 @@
|
||||||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
||||||
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
||||||
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
|
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
|
||||||
|
DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */,
|
||||||
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */,
|
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */,
|
||||||
DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */,
|
DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */,
|
||||||
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
|
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
|
||||||
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
|
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
|
||||||
DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */,
|
DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */,
|
||||||
|
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */,
|
||||||
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
||||||
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
|
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
|
||||||
2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */,
|
2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */,
|
||||||
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
|
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
|
||||||
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
|
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
|
||||||
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
|
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
|
||||||
|
DB97131F2666078B00BD1E90 /* Date.swift in Sources */,
|
||||||
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
|
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
|
||||||
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */,
|
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */,
|
||||||
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
|
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
|
||||||
|
@ -3045,6 +3071,7 @@
|
||||||
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
|
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
|
||||||
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
|
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
|
||||||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
|
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
|
||||||
|
DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */,
|
||||||
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */,
|
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */,
|
||||||
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
|
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
|
||||||
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */,
|
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */,
|
||||||
|
@ -3059,6 +3086,7 @@
|
||||||
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
|
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
|
||||||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||||
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
|
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
|
||||||
|
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */,
|
||||||
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
|
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
|
||||||
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
|
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
|
||||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||||
|
@ -3108,13 +3136,13 @@
|
||||||
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
|
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
|
||||||
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
|
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
|
||||||
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
|
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
|
||||||
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
|
|
||||||
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
|
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
|
||||||
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
|
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
|
||||||
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
|
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
|
||||||
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
|
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
|
||||||
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
|
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
|
||||||
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
|
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
|
||||||
|
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */,
|
||||||
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
|
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
|
||||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||||
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
|
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
|
||||||
|
@ -3152,6 +3180,7 @@
|
||||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||||
DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */,
|
DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */,
|
||||||
|
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */,
|
||||||
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
|
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||||
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
||||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
||||||
|
@ -3177,7 +3206,6 @@
|
||||||
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
|
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
|
||||||
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
||||||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
||||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
|
||||||
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
||||||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
||||||
DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */,
|
DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */,
|
||||||
|
@ -3237,7 +3265,6 @@
|
||||||
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
||||||
DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */,
|
DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */,
|
||||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
||||||
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
|
|
||||||
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
||||||
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
|
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
|
||||||
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
|
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
|
||||||
|
@ -3261,6 +3288,7 @@
|
||||||
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
|
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
|
||||||
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
|
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
|
||||||
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
||||||
|
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
|
||||||
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */,
|
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */,
|
||||||
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
|
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
|
||||||
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */,
|
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */,
|
||||||
|
@ -3586,16 +3614,16 @@
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.0;
|
MARKETING_VERSION = 0.6.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon;
|
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
@ -3613,16 +3641,16 @@
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.0;
|
MARKETING_VERSION = 0.6.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon;
|
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
@ -3636,7 +3664,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = MastodonTests/Info.plist;
|
INFOPLIST_FILE = MastodonTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
@ -3657,7 +3685,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = MastodonTests/Info.plist;
|
INFOPLIST_FILE = MastodonTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
@ -3677,7 +3705,7 @@
|
||||||
baseConfigurationReference = 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */;
|
baseConfigurationReference = 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = MastodonUITests/Info.plist;
|
INFOPLIST_FILE = MastodonUITests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
@ -3697,7 +3725,7 @@
|
||||||
baseConfigurationReference = BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */;
|
baseConfigurationReference = BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = MastodonUITests/Info.plist;
|
INFOPLIST_FILE = MastodonUITests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
@ -3714,14 +3742,14 @@
|
||||||
};
|
};
|
||||||
DB6804892637CD4C00430867 /* Debug */ = {
|
DB6804892637CD4C00430867 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */;
|
baseConfigurationReference = 9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEFINES_MODULE = YES;
|
DEFINES_MODULE = YES;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||||
DYLIB_CURRENT_VERSION = 1;
|
DYLIB_CURRENT_VERSION = 1;
|
||||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||||
|
@ -3732,7 +3760,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared;
|
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
@ -3745,14 +3773,14 @@
|
||||||
};
|
};
|
||||||
DB68048A2637CD4C00430867 /* Release */ = {
|
DB68048A2637CD4C00430867 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */;
|
baseConfigurationReference = ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEFINES_MODULE = YES;
|
DEFINES_MODULE = YES;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||||
DYLIB_CURRENT_VERSION = 1;
|
DYLIB_CURRENT_VERSION = 1;
|
||||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||||
|
@ -3763,7 +3791,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared;
|
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
@ -3780,7 +3808,7 @@
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEFINES_MODULE = YES;
|
DEFINES_MODULE = YES;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||||
DYLIB_CURRENT_VERSION = 1;
|
DYLIB_CURRENT_VERSION = 1;
|
||||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||||
|
@ -3809,7 +3837,7 @@
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEFINES_MODULE = YES;
|
DEFINES_MODULE = YES;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||||
DYLIB_CURRENT_VERSION = 1;
|
DYLIB_CURRENT_VERSION = 1;
|
||||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||||
|
@ -3835,7 +3863,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = CoreDataStackTests/Info.plist;
|
INFOPLIST_FILE = CoreDataStackTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
@ -3855,7 +3883,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = CoreDataStackTests/Info.plist;
|
INFOPLIST_FILE = CoreDataStackTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
@ -3872,20 +3900,20 @@
|
||||||
};
|
};
|
||||||
DBF8AE1C263293E400C9C23C /* Debug */ = {
|
DBF8AE1C263293E400C9C23C /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */;
|
baseConfigurationReference = 9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.0;
|
MARKETING_VERSION = 0.6.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService;
|
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
@ -3895,20 +3923,20 @@
|
||||||
};
|
};
|
||||||
DBF8AE1D263293E400C9C23C /* Release */ = {
|
DBF8AE1D263293E400C9C23C /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */;
|
baseConfigurationReference = 9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.0;
|
MARKETING_VERSION = 0.6.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService;
|
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<key>AppShared.xcscheme_^#shared#^_</key>
|
<key>AppShared.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>18</integer>
|
<integer>14</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -27,12 +27,12 @@
|
||||||
<key>Mastodon.xcscheme_^#shared#^_</key>
|
<key>Mastodon.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>13</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>14</integer>
|
<integer>12</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|
|
@ -18,7 +18,7 @@ final class SafariActivity: UIActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
override var activityType: UIActivity.ActivityType? {
|
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? {
|
override var activityTitle: String? {
|
||||||
|
|
|
@ -42,7 +42,6 @@ extension SceneCoordinator {
|
||||||
// onboarding
|
// onboarding
|
||||||
case welcome
|
case welcome
|
||||||
case mastodonPickServer(viewMode: MastodonPickServerViewModel)
|
case mastodonPickServer(viewMode: MastodonPickServerViewModel)
|
||||||
case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel)
|
|
||||||
case mastodonRegister(viewModel: MastodonRegisterViewModel)
|
case mastodonRegister(viewModel: MastodonRegisterViewModel)
|
||||||
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
|
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
|
||||||
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
||||||
|
@ -78,6 +77,7 @@ extension SceneCoordinator {
|
||||||
case safari(url: URL)
|
case safari(url: URL)
|
||||||
case alertController(alertController: UIAlertController)
|
case alertController(alertController: UIAlertController)
|
||||||
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
|
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
case publicTimeline
|
case publicTimeline
|
||||||
#endif
|
#endif
|
||||||
|
@ -86,7 +86,6 @@ extension SceneCoordinator {
|
||||||
switch self {
|
switch self {
|
||||||
case .welcome,
|
case .welcome,
|
||||||
.mastodonPickServer,
|
.mastodonPickServer,
|
||||||
.mastodonPinBasedAuthentication,
|
|
||||||
.mastodonRegister,
|
.mastodonRegister,
|
||||||
.mastodonServerRules,
|
.mastodonServerRules,
|
||||||
.mastodonConfirmEmail,
|
.mastodonConfirmEmail,
|
||||||
|
@ -217,10 +216,6 @@ private extension SceneCoordinator {
|
||||||
let _viewController = MastodonPickServerViewController()
|
let _viewController = MastodonPickServerViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
viewController = _viewController
|
viewController = _viewController
|
||||||
case .mastodonPinBasedAuthentication(let viewModel):
|
|
||||||
let _viewController = MastodonPinBasedAuthenticationViewController()
|
|
||||||
_viewController.viewModel = viewModel
|
|
||||||
viewController = _viewController
|
|
||||||
case .mastodonRegister(let viewModel):
|
case .mastodonRegister(let viewModel):
|
||||||
let _viewController = MastodonRegisterViewController()
|
let _viewController = MastodonRegisterViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
|
@ -261,10 +256,6 @@ private extension SceneCoordinator {
|
||||||
let _viewController = FavoriteViewController()
|
let _viewController = FavoriteViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
viewController = _viewController
|
viewController = _viewController
|
||||||
case .settings(let viewModel):
|
|
||||||
let _viewController = SettingsViewController()
|
|
||||||
_viewController.viewModel = viewModel
|
|
||||||
viewController = _viewController
|
|
||||||
case .suggestionAccount(let viewModel):
|
case .suggestionAccount(let viewModel):
|
||||||
let _viewController = SuggestionAccountViewController()
|
let _viewController = SuggestionAccountViewController()
|
||||||
_viewController.viewModel = viewModel
|
_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 {
|
switch item {
|
||||||
case .replyTo(let replyToStatusObjectID):
|
case .replyTo(let replyToStatusObjectID):
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell
|
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 {
|
guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -73,7 +76,7 @@ extension ComposeStatusSection {
|
||||||
//status.emoji
|
//status.emoji
|
||||||
cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
|
cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
|
||||||
// set date
|
// 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)
|
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
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
|
||||||
cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value
|
cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value
|
||||||
cell.textEditorView.text = attribute.composeContent.value ?? ""
|
cell.textEditorView.text = attribute.composeContent.value ?? ""
|
||||||
managedObjectContext.perform {
|
managedObjectContext.performAndWait {
|
||||||
guard let replyToStatusObjectID = replyToStatusObjectID,
|
guard let replyToStatusObjectID = replyToStatusObjectID,
|
||||||
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||||
cell.statusView.headerContainerView.isHidden = true
|
cell.statusView.headerContainerView.isHidden = true
|
||||||
|
@ -98,21 +101,24 @@ extension ComposeStatusSection {
|
||||||
cell.composeContent
|
cell.composeContent
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { text in
|
.sink { [weak collectionView] text in
|
||||||
|
guard let collectionView = collectionView else { return }
|
||||||
// self size input cell
|
// self size input cell
|
||||||
// needs restore content offset to resolve issue #83
|
// needs restore content offset to resolve issue #83
|
||||||
let oldContentOffset = collectionView.contentOffset
|
let oldContentOffset = collectionView.contentOffset
|
||||||
collectionView.collectionViewLayout.invalidateLayout()
|
collectionView.collectionViewLayout.invalidateLayout()
|
||||||
collectionView.layoutIfNeeded()
|
collectionView.layoutIfNeeded()
|
||||||
collectionView.contentOffset = oldContentOffset
|
collectionView.contentOffset = oldContentOffset
|
||||||
|
|
||||||
// bind input data
|
// bind input data
|
||||||
attribute.composeContent.value = text
|
attribute.composeContent.value = text
|
||||||
}
|
}
|
||||||
.store(in: &cell.disposeBag)
|
.store(in: &cell.disposeBag)
|
||||||
attribute.isContentWarningComposing
|
attribute.isContentWarningComposing
|
||||||
.receive(on: DispatchQueue.main)
|
.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
|
// self size input cell
|
||||||
collectionView.collectionViewLayout.invalidateLayout()
|
collectionView.collectionViewLayout.invalidateLayout()
|
||||||
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
|
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
|
||||||
|
@ -127,27 +133,28 @@ extension ComposeStatusSection {
|
||||||
cell.contentWarningContent
|
cell.contentWarningContent
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { text in
|
.sink { [weak collectionView] text in
|
||||||
|
guard let collectionView = collectionView else { return }
|
||||||
// self size input cell
|
// self size input cell
|
||||||
collectionView.collectionViewLayout.invalidateLayout()
|
collectionView.collectionViewLayout.invalidateLayout()
|
||||||
// bind input data
|
// bind input data
|
||||||
attribute.contentWarningContent.value = text
|
attribute.contentWarningContent.value = text
|
||||||
}
|
}
|
||||||
.store(in: &cell.disposeBag)
|
.store(in: &cell.disposeBag)
|
||||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
|
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
|
||||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
|
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
case .attachment(let attachmentService):
|
case .attachment(let attachmentService):
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell
|
||||||
cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value
|
cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value
|
||||||
cell.delegate = composeStatusAttachmentTableViewCellDelegate
|
cell.delegate = composeStatusAttachmentTableViewCellDelegate
|
||||||
attachmentService.imageData
|
attachmentService.thumbnailImage
|
||||||
.receive(on: DispatchQueue.main)
|
.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)
|
let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1)
|
||||||
guard let imageData = imageData,
|
guard let image = thumbnailImage else {
|
||||||
let image = UIImage(data: imageData) else {
|
|
||||||
let placeholder = UIImage.placeholder(
|
let placeholder = UIImage.placeholder(
|
||||||
size: size,
|
size: size,
|
||||||
color: Asset.Colors.Background.systemGroupedBackground.color
|
color: Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
@ -168,17 +175,32 @@ extension ComposeStatusSection {
|
||||||
attachmentService.error.eraseToAnyPublisher()
|
attachmentService.error.eraseToAnyPublisher()
|
||||||
)
|
)
|
||||||
.receive(on: DispatchQueue.main)
|
.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.emptyStateView.isHidden = error == nil
|
||||||
cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil
|
cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil
|
||||||
if let _ = error {
|
if let error = error {
|
||||||
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
||||||
|
cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription
|
||||||
} else {
|
} else {
|
||||||
guard let uploadState = uploadState else { return }
|
guard let uploadState = uploadState else { return }
|
||||||
switch uploadState {
|
switch uploadState {
|
||||||
case is MastodonAttachmentService.UploadState.Finish,
|
case is MastodonAttachmentService.UploadState.Finish,
|
||||||
is MastodonAttachmentService.UploadState.Fail:
|
is MastodonAttachmentService.UploadState.Fail:
|
||||||
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -206,7 +228,7 @@ extension ComposeStatusSection {
|
||||||
.assign(to: \.value, on: attribute.option)
|
.assign(to: \.value, on: attribute.option)
|
||||||
.store(in: &cell.disposeBag)
|
.store(in: &cell.disposeBag)
|
||||||
cell.delegate = composeStatusPollOptionCollectionViewCellDelegate
|
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
|
return cell
|
||||||
case .pollOptionAppendEntry:
|
case .pollOptionAppendEntry:
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell
|
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)
|
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal)
|
||||||
attribute.expiresOption
|
attribute.expiresOption
|
||||||
.receive(on: DispatchQueue.main)
|
.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)
|
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal)
|
||||||
}
|
}
|
||||||
.store(in: &cell.disposeBag)
|
.store(in: &cell.disposeBag)
|
||||||
|
@ -272,7 +295,7 @@ protocol CustomEmojiReplaceableTextInput: AnyObject {
|
||||||
var isFirstResponder: Bool { get }
|
var isFirstResponder: Bool { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
class CustomEmojiReplacableTextInputReference {
|
class CustomEmojiReplaceableTextInputReference {
|
||||||
weak var value: CustomEmojiReplaceableTextInput?
|
weak var value: CustomEmojiReplaceableTextInput?
|
||||||
|
|
||||||
init(value: CustomEmojiReplaceableTextInput? = nil) {
|
init(value: CustomEmojiReplaceableTextInput? = nil) {
|
||||||
|
@ -297,7 +320,7 @@ extension ComposeStatusSection {
|
||||||
|
|
||||||
static func configureCustomEmojiPicker(
|
static func configureCustomEmojiPicker(
|
||||||
viewModel: CustomEmojiPickerInputViewModel?,
|
viewModel: CustomEmojiPickerInputViewModel?,
|
||||||
customEmojiReplacableTextInput: CustomEmojiReplaceableTextInput,
|
customEmojiReplaceableTextInput: CustomEmojiReplaceableTextInput,
|
||||||
disposeBag: inout Set<AnyCancellable>
|
disposeBag: inout Set<AnyCancellable>
|
||||||
) {
|
) {
|
||||||
guard let viewModel = viewModel else { return }
|
guard let viewModel = viewModel else { return }
|
||||||
|
@ -305,9 +328,9 @@ extension ComposeStatusSection {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak viewModel] isCustomEmojiComposing in
|
.sink { [weak viewModel] isCustomEmojiComposing in
|
||||||
guard let viewModel = viewModel else { return }
|
guard let viewModel = viewModel else { return }
|
||||||
customEmojiReplacableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
|
customEmojiReplaceableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
|
||||||
customEmojiReplacableTextInput.reloadInputViews()
|
customEmojiReplaceableTextInput.reloadInputViews()
|
||||||
viewModel.append(customEmojiReplacableTextInput: customEmojiReplacableTextInput)
|
viewModel.append(customEmojiReplaceableTextInput: customEmojiReplaceableTextInput)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ extension NotificationSection {
|
||||||
assertionFailure()
|
assertionFailure()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||||
|
|
||||||
let actionText = type.actionText
|
let actionText = type.actionText
|
||||||
let actionImageName = type.actionImageName
|
let actionImageName = type.actionImageName
|
||||||
|
@ -59,7 +59,7 @@ extension NotificationSection {
|
||||||
)
|
)
|
||||||
timestampUpdatePublisher
|
timestampUpdatePublisher
|
||||||
.sink { _ in
|
.sink { _ in
|
||||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||||
cell.actionLabel.text = actionText + " · " + timeText
|
cell.actionLabel.text = actionText + " · " + timeText
|
||||||
}
|
}
|
||||||
.store(in: &cell.disposeBag)
|
.store(in: &cell.disposeBag)
|
||||||
|
@ -87,7 +87,7 @@ extension NotificationSection {
|
||||||
cell.delegate = delegate
|
cell.delegate = delegate
|
||||||
timestampUpdatePublisher
|
timestampUpdatePublisher
|
||||||
.sink { _ in
|
.sink { _ in
|
||||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
let timeText = notification.createAt.slowedTimeAgoSinceNow
|
||||||
cell.actionLabel.text = actionText + " · " + timeText
|
cell.actionLabel.text = actionText + " · " + timeText
|
||||||
}
|
}
|
||||||
.store(in: &cell.disposeBag)
|
.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 {
|
} else {
|
||||||
meta.blurhashImagePublisher()
|
meta.blurhashImagePublisher()
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak cell] image in
|
.sink { [weak blurhashImageCache] image in
|
||||||
|
guard let blurhashImageCache = blurhashImageCache else { return }
|
||||||
blurhashOverlayImageView.image = image
|
blurhashOverlayImageView.image = image
|
||||||
image?.pngData().flatMap {
|
image?.pngData().flatMap {
|
||||||
blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey)
|
blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey)
|
||||||
|
@ -467,12 +468,12 @@ extension StatusSection {
|
||||||
|
|
||||||
// set date
|
// set date
|
||||||
let createdAt = (status.reblog ?? status).createdAt
|
let createdAt = (status.reblog ?? status).createdAt
|
||||||
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
|
||||||
timestampUpdatePublisher
|
timestampUpdatePublisher
|
||||||
.sink { [weak cell] _ in
|
.sink { [weak cell] _ in
|
||||||
guard let cell = cell else { return }
|
guard let cell = cell else { return }
|
||||||
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
|
||||||
cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow
|
cell.statusView.dateLabel.accessibilityLabel = createdAt.slowedTimeAgoSinceNow
|
||||||
}
|
}
|
||||||
.store(in: &cell.disposeBag)
|
.store(in: &cell.disposeBag)
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,8 @@ extension ActiveLabel {
|
||||||
case `default`
|
case `default`
|
||||||
case statusHeader
|
case statusHeader
|
||||||
case statusName
|
case statusName
|
||||||
case profileField
|
case profileFieldName
|
||||||
|
case profileFieldValue
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(style: Style) {
|
convenience init(style: Style) {
|
||||||
|
@ -46,8 +47,12 @@ extension ActiveLabel {
|
||||||
font = .systemFont(ofSize: 17, weight: .semibold)
|
font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
textColor = Asset.Colors.Label.primary.color
|
textColor = Asset.Colors.Label.primary.color
|
||||||
numberOfLines = 1
|
numberOfLines = 1
|
||||||
case .profileField:
|
case .profileFieldName:
|
||||||
font = .preferredFont(forTextStyle: .body)
|
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
|
textColor = Asset.Colors.Label.primary.color
|
||||||
numberOfLines = 1
|
numberOfLines = 1
|
||||||
}
|
}
|
||||||
|
@ -78,10 +83,10 @@ extension ActiveLabel {
|
||||||
|
|
||||||
extension ActiveLabel {
|
extension ActiveLabel {
|
||||||
/// account field
|
/// account field
|
||||||
func configure(field: String) {
|
func configure(field: String, emojiDict: MastodonStatusContent.EmojiDict) {
|
||||||
activeEntities.removeAll()
|
activeEntities.removeAll()
|
||||||
let parseResult = MastodonField.parse(field: field)
|
let parseResult = MastodonField.parse(field: field, emojiDict: emojiDict)
|
||||||
text = parseResult.value
|
text = parseResult.trimmed
|
||||||
activeEntities = parseResult.activeEntities
|
activeEntities = parseResult.activeEntities
|
||||||
accessibilityLabel = parseResult.value
|
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,
|
headerStatic: entity.headerStatic,
|
||||||
note: entity.note,
|
note: entity.note,
|
||||||
url: entity.url,
|
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,
|
statusesCount: entity.statusesCount,
|
||||||
followingCount: entity.followingCount,
|
followingCount: entity.followingCount,
|
||||||
followersCount: entity.followersCount,
|
followersCount: entity.followersCount,
|
||||||
|
@ -101,3 +102,4 @@ extension MastodonUser {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MastodonUser: EmojiContinaer { }
|
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))
|
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 {
|
internal enum Common {
|
||||||
/// Please try again.
|
/// Please try again.
|
||||||
internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain")
|
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")
|
internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message")
|
||||||
/// Publish Failure
|
/// Publish Failure
|
||||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title")
|
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 {
|
internal enum SavePhotoFailure {
|
||||||
/// Please enable photo libaray access permission to save photo.
|
/// Please enable photo libaray access permission to save photo.
|
||||||
|
@ -366,6 +380,10 @@ internal enum L10n {
|
||||||
/// Show more replies
|
/// Show more replies
|
||||||
internal static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies")
|
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 {
|
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 RelationshipActionAlert {
|
||||||
internal enum ConfirmUnblockUsre {
|
internal enum ConfirmUnblockUsre {
|
||||||
/// Confirm unblock %@
|
/// Confirm unblock %@
|
||||||
|
|
|
@ -10,12 +10,25 @@ import ActiveLabel
|
||||||
|
|
||||||
enum MastodonField {
|
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 mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)")
|
||||||
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
|
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
|
||||||
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
|
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
|
||||||
|
|
||||||
var entities: [ActiveEntity] = []
|
|
||||||
|
|
||||||
for match in mentionMatches {
|
for match in mentionMatches {
|
||||||
guard let text = string.substring(with: match, at: 0) else { continue }
|
guard let text = string.substring(with: match, at: 0) else { continue }
|
||||||
|
@ -35,7 +48,7 @@ enum MastodonField {
|
||||||
entities.append(entity)
|
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 {
|
extension MastodonField {
|
||||||
struct ParseResult {
|
struct ParseResult {
|
||||||
let value: String
|
let value: String
|
||||||
|
let trimmed: String
|
||||||
let activeEntities: [ActiveEntity]
|
let activeEntities: [ActiveEntity]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ enum MastodonStatusContent {
|
||||||
let pattern = ":\(shortcode):"
|
let pattern = ":\(shortcode):"
|
||||||
content = content.replacingOccurrences(of: pattern, with: emojiNode)
|
content = content.replacingOccurrences(of: pattern, with: emojiNode)
|
||||||
}
|
}
|
||||||
return content
|
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}()
|
}()
|
||||||
let rootNode = try Node.parse(document: document)
|
let rootNode = try Node.parse(document: document)
|
||||||
let text = String(rootNode.text)
|
let text = String(rootNode.text)
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.org.joinmastodon.mastodon-temp</string>
|
<string>group.org.joinmastodon.app</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -129,7 +129,17 @@ extension StatusProviderFacade {
|
||||||
coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text)
|
coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text)
|
||||||
case .url(_, _, let url, _):
|
case .url(_, _, let url, _):
|
||||||
guard let url = URL(string: url) else { return }
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
|
"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.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.PleaseTryAgain" = "Please try again.";
|
||||||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||||
"Common.Alerts.DeletePost.Delete" = "Delete";
|
"Common.Alerts.DeletePost.Delete" = "Delete";
|
||||||
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
|
"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.Message" = "Confirm discard composed post content.";
|
||||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
"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.
|
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||||
Please check your internet connection.";
|
Please check your internet connection.";
|
||||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
"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.LoadMissingPosts" = "Load missing posts";
|
||||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||||
|
"Common.Controls.Timeline.Timestamp.Now" = "Now";
|
||||||
"Common.Countable.Photo.Multiple" = "photos";
|
"Common.Countable.Photo.Multiple" = "photos";
|
||||||
"Common.Countable.Photo.Single" = "photo";
|
"Common.Countable.Photo.Single" = "photo";
|
||||||
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
|
"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.Followers" = "followers";
|
||||||
"Scene.Profile.Dashboard.Following" = "following";
|
"Scene.Profile.Dashboard.Following" = "following";
|
||||||
"Scene.Profile.Dashboard.Posts" = "posts";
|
"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.Message" = "Confirm unblock %@";
|
||||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
|
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
|
||||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
|
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
|
"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.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.PleaseTryAgain" = "Please try again.";
|
||||||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||||
"Common.Alerts.DeletePost.Delete" = "Delete";
|
"Common.Alerts.DeletePost.Delete" = "Delete";
|
||||||
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
|
"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.Message" = "Confirm discard composed post content.";
|
||||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
"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.
|
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||||
Please check your internet connection.";
|
Please check your internet connection.";
|
||||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
"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.LoadMissingPosts" = "Load missing posts";
|
||||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||||
|
"Common.Controls.Timeline.Timestamp.Now" = "Now";
|
||||||
"Common.Countable.Photo.Multiple" = "photos";
|
"Common.Countable.Photo.Multiple" = "photos";
|
||||||
"Common.Countable.Photo.Single" = "photo";
|
"Common.Countable.Photo.Single" = "photo";
|
||||||
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
|
"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.Followers" = "followers";
|
||||||
"Scene.Profile.Dashboard.Following" = "following";
|
"Scene.Profile.Dashboard.Following" = "following";
|
||||||
"Scene.Profile.Dashboard.Posts" = "posts";
|
"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.Message" = "Confirm unblock %@";
|
||||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
|
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
|
||||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
|
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
|
||||||
|
|
|
@ -57,6 +57,10 @@ final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell {
|
||||||
_init()
|
_init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComposeStatusAttachmentCollectionViewCell {
|
extension ComposeStatusAttachmentCollectionViewCell {
|
||||||
|
|
|
@ -61,7 +61,8 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
||||||
var systemKeyboardHeight: CGFloat = .zero {
|
var systemKeyboardHeight: CGFloat = .zero {
|
||||||
didSet {
|
didSet {
|
||||||
// note: some system AutoLayout warning here
|
// 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!
|
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
|
||||||
let composeToolbarBackgroundView = UIView()
|
let composeToolbarBackgroundView = UIView()
|
||||||
|
|
||||||
private(set) lazy var imagePicker: PHPickerViewController = {
|
static func createPhotoLibraryPickerConfiguration(selectionLimit: Int = 4) -> PHPickerConfiguration {
|
||||||
var configuration = PHPickerConfiguration()
|
var configuration = PHPickerConfiguration()
|
||||||
configuration.filter = .images
|
configuration.filter = .any(of: [.images, .videos])
|
||||||
configuration.selectionLimit = 4
|
configuration.selectionLimit = selectionLimit
|
||||||
|
return configuration
|
||||||
let imagePicker = PHPickerViewController(configuration: configuration)
|
}
|
||||||
|
|
||||||
|
private(set) lazy var photoLibraryPicker: PHPickerViewController = {
|
||||||
|
let imagePicker = PHPickerViewController(configuration: ComposeViewController.createPhotoLibraryPickerConfiguration())
|
||||||
imagePicker.delegate = self
|
imagePicker.delegate = self
|
||||||
return imagePicker
|
return imagePicker
|
||||||
}()
|
}()
|
||||||
|
@ -92,7 +96,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
|
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
|
||||||
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
|
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image, .movie])
|
||||||
documentPickerController.delegate = self
|
documentPickerController.delegate = self
|
||||||
return documentPickerController
|
return documentPickerController
|
||||||
}()
|
}()
|
||||||
|
@ -567,12 +571,9 @@ extension ComposeViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetImagePicker() {
|
private func resetImagePicker() {
|
||||||
var configuration = PHPickerConfiguration()
|
|
||||||
configuration.filter = .images
|
|
||||||
let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count)
|
let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count)
|
||||||
configuration.selectionLimit = selectionLimit
|
let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit)
|
||||||
|
photoLibraryPicker = createImagePicker(configuration: configuration)
|
||||||
imagePicker = createImagePicker(configuration: configuration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController {
|
private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController {
|
||||||
|
@ -610,6 +611,16 @@ extension ComposeViewController {
|
||||||
|
|
||||||
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
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 {
|
guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else {
|
||||||
// TODO: handle error
|
// TODO: handle error
|
||||||
return
|
return
|
||||||
|
@ -720,7 +731,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
|
||||||
guard let emoji = customEmojiViewModel.emoji(shortcode: name) else { continue }
|
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)
|
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]()
|
var attributes = [NSAttributedString.Key: Any]()
|
||||||
attributes[.font] = UIFont.systemFont(ofSize: 0.01)
|
attributes[.font] = UIFont.systemFont(ofSize: 0.01)
|
||||||
attributedString.addAttributes(attributes, range: match.range)
|
attributedString.addAttributes(attributes, range: match.range)
|
||||||
|
@ -802,15 +813,15 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
|
||||||
extension ComposeViewController: TextEditorViewChangeObserver {
|
extension ComposeViewController: TextEditorViewChangeObserver {
|
||||||
|
|
||||||
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
|
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
|
viewModel.autoCompleteInfo.value = nil
|
||||||
return
|
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
|
// get layout text bounding rect
|
||||||
var glyphRange = NSRange()
|
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 textContainer = textEditorView.layoutManager.textContainers[0]
|
||||||
let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||||
|
|
||||||
|
@ -828,13 +839,13 @@ extension ComposeViewController: TextEditorViewChangeObserver {
|
||||||
viewModel.autoCompleteRetryLayoutTimes.value = 0
|
viewModel.autoCompleteRetryLayoutTimes.value = 0
|
||||||
|
|
||||||
// get symbol bounding rect
|
// 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)
|
let symbolBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||||
|
|
||||||
// set bounding rect and trigger layout
|
// set bounding rect and trigger layout
|
||||||
autoCompeletion.textBoundingRect = textBoundingRect
|
autoCompletion.textBoundingRect = textBoundingRect
|
||||||
autoCompeletion.symbolBoundingRect = symbolBoundingRect
|
autoCompletion.symbolBoundingRect = symbolBoundingRect
|
||||||
viewModel.autoCompleteInfo.value = autoCompeletion
|
viewModel.autoCompleteInfo.value = autoCompletion
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AutoCompleteInfo {
|
struct AutoCompleteInfo {
|
||||||
|
@ -854,12 +865,14 @@ extension ComposeViewController: TextEditorViewChangeObserver {
|
||||||
|
|
||||||
private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? {
|
private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? {
|
||||||
let text = textEditorView.text
|
let text = textEditorView.text
|
||||||
let cursorLocation = textEditorView.selectedRange.location
|
|
||||||
let cursorIndex = text.index(text.startIndex, offsetBy: cursorLocation)
|
guard textEditorView.selectedRange.location > 0, !text.isEmpty,
|
||||||
guard cursorLocation > 0, !text.isEmpty else { return nil }
|
let selectedRange = Range(textEditorView.selectedRange, in: text) else {
|
||||||
|
return nil
|
||||||
let _highlighStartIndex: String.Index? = {
|
}
|
||||||
var index = text.index(text.startIndex, offsetBy: cursorLocation - 1)
|
let cursorIndex = selectedRange.upperBound
|
||||||
|
let _highlightStartIndex: String.Index? = {
|
||||||
|
var index = text.index(before: cursorIndex)
|
||||||
while index > text.startIndex {
|
while index > text.startIndex {
|
||||||
let char = text[index]
|
let char = text[index]
|
||||||
if char == "@" || char == "#" || char == ":" {
|
if char == "@" || char == "#" || char == ":" {
|
||||||
|
@ -876,18 +889,18 @@ extension ComposeViewController: TextEditorViewChangeObserver {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
guard let highlighStartIndex = _highlighStartIndex else { return nil }
|
guard let highlightStartIndex = _highlightStartIndex else { return nil }
|
||||||
let scanRange = NSRange(highlighStartIndex..<text.endIndex, in: text)
|
let scanRange = NSRange(highlightStartIndex..<text.endIndex, in: text)
|
||||||
|
|
||||||
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
|
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
|
||||||
let matchRange = match.range(at: 0)
|
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
|
||||||
let matchStartIndex = text.index(text.startIndex, offsetBy: matchRange.location)
|
let matchStartIndex = matchRange.lowerBound
|
||||||
let matchEndIndex = text.index(matchStartIndex, offsetBy: matchRange.length)
|
let matchEndIndex = matchRange.upperBound
|
||||||
|
|
||||||
guard matchStartIndex == highlighStartIndex, matchEndIndex >= cursorIndex else { return nil }
|
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
|
||||||
let symbolRange = highlighStartIndex..<text.index(after: highlighStartIndex)
|
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
|
||||||
let symbolString = text[symbolRange]
|
let symbolString = text[symbolRange]
|
||||||
let toCursorRange = highlighStartIndex..<cursorIndex
|
let toCursorRange = highlightStartIndex..<cursorIndex
|
||||||
let toCursorString = text[toCursorRange]
|
let toCursorString = text[toCursorRange]
|
||||||
let toHighlightEndRange = matchStartIndex..<matchEndIndex
|
let toHighlightEndRange = matchStartIndex..<matchEndIndex
|
||||||
let toHighlightEndString = text[toHighlightEndRange]
|
let toHighlightEndString = text[toHighlightEndRange]
|
||||||
|
@ -913,7 +926,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
|
||||||
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) {
|
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) {
|
||||||
switch type {
|
switch type {
|
||||||
case .photoLibrary:
|
case .photoLibrary:
|
||||||
present(imagePicker, animated: true, completion: nil)
|
present(photoLibraryPicker, animated: true, completion: nil)
|
||||||
case .camera:
|
case .camera:
|
||||||
present(imagePickerController, animated: true, completion: nil)
|
present(imagePickerController, animated: true, completion: nil)
|
||||||
case .browse:
|
case .browse:
|
||||||
|
@ -1005,7 +1018,7 @@ extension ComposeViewController: UICollectionViewDelegate {
|
||||||
let emoji = attribute.emoji
|
let emoji = attribute.emoji
|
||||||
let textEditorView = self.textEditorView()
|
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
|
// the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue
|
||||||
let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ")
|
let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ")
|
||||||
|
|
||||||
|
@ -1018,6 +1031,9 @@ extension ComposeViewController: UICollectionViewDelegate {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||||
|
|
||||||
|
// make click sound
|
||||||
|
UIDevice.current.playInputClick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -1058,7 +1074,7 @@ extension ComposeViewController: PHPickerViewControllerDelegate {
|
||||||
let service = MastodonAttachmentService(
|
let service = MastodonAttachmentService(
|
||||||
context: context,
|
context: context,
|
||||||
pickerResult: result,
|
pickerResult: result,
|
||||||
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
|
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||||
)
|
)
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
@ -1077,7 +1093,7 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC
|
||||||
let attachmentService = MastodonAttachmentService(
|
let attachmentService = MastodonAttachmentService(
|
||||||
context: context,
|
context: context,
|
||||||
image: image,
|
image: image,
|
||||||
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
|
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||||
)
|
)
|
||||||
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
|
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
|
||||||
}
|
}
|
||||||
|
@ -1092,20 +1108,13 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC
|
||||||
extension ComposeViewController: UIDocumentPickerDelegate {
|
extension ComposeViewController: UIDocumentPickerDelegate {
|
||||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
guard let url = urls.first else { return }
|
guard let url = urls.first else { return }
|
||||||
|
|
||||||
do {
|
let attachmentService = MastodonAttachmentService(
|
||||||
guard url.startAccessingSecurityScopedResource() else { return }
|
context: context,
|
||||||
defer { url.stopAccessingSecurityScopedResource() }
|
documentURL: url,
|
||||||
let imageData = try Data(contentsOf: url)
|
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
|
||||||
let attachmentService = MastodonAttachmentService(
|
)
|
||||||
context: context,
|
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1120,8 +1129,12 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega
|
||||||
|
|
||||||
var attachmentServices = viewModel.attachmentServices.value
|
var attachmentServices = viewModel.attachmentServices.value
|
||||||
guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
|
guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
|
||||||
|
let removedItem = attachmentServices[index]
|
||||||
attachmentServices.remove(at: index)
|
attachmentServices.remove(at: index)
|
||||||
viewModel.attachmentServices.value = attachmentServices
|
viewModel.attachmentServices.value = attachmentServices
|
||||||
|
|
||||||
|
// cancel task
|
||||||
|
removedItem.disposeBag.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1365,7 +1378,7 @@ extension ComposeViewController {
|
||||||
case .mediaBrowse:
|
case .mediaBrowse:
|
||||||
present(documentPickerController, animated: true, completion: nil)
|
present(documentPickerController, animated: true, completion: nil)
|
||||||
case .mediaPhotoLibrary:
|
case .mediaPhotoLibrary:
|
||||||
present(imagePicker, animated: true, completion: nil)
|
present(photoLibraryPicker, animated: true, completion: nil)
|
||||||
case .mediaCamera:
|
case .mediaCamera:
|
||||||
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
|
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
|
||||||
return
|
return
|
||||||
|
|
|
@ -291,6 +291,8 @@ final class ComposeViewModel {
|
||||||
)
|
)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] attachmentServices, isPollComposing, pollAttributes in
|
.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 self = self else { return }
|
||||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||||
var snapshot = diffableDataSource.snapshot()
|
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
|
// MARK: - MastodonAttachmentServiceDelegate
|
||||||
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
|
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
|
||||||
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
|
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
|
||||||
|
|
|
@ -29,6 +29,8 @@ extension AttachmentContainerView {
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
||||||
label.numberOfLines = 2
|
label.numberOfLines = 2
|
||||||
|
label.adjustsFontSizeToFitWidth = true
|
||||||
|
label.minimumScaleFactor = 0.3
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -218,6 +218,12 @@ extension ComposeToolbarView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateToolbarButtonUserInterfaceStyle() {
|
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 {
|
switch traitCollection.userInterfaceStyle {
|
||||||
case .light:
|
case .light:
|
||||||
mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
|
mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
|
||||||
|
|
|
@ -84,3 +84,9 @@ extension CustomEmojiPickerInputView {
|
||||||
return layout
|
return layout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension CustomEmojiPickerInputView: UIInputViewAudioFeedback {
|
||||||
|
var enableInputClicksWhenVisible: Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ final class CustomEmojiPickerInputViewModel {
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
private var customEmojiReplacableTextInputReferences: [CustomEmojiReplacableTextInputReference] = []
|
private var customEmojiReplaceableTextInputReferences: [CustomEmojiReplaceableTextInputReference] = []
|
||||||
|
|
||||||
// input
|
// input
|
||||||
weak var customEmojiPickerInputView: CustomEmojiPickerInputView?
|
weak var customEmojiPickerInputView: CustomEmojiPickerInputView?
|
||||||
|
@ -25,27 +25,27 @@ final class CustomEmojiPickerInputViewModel {
|
||||||
extension CustomEmojiPickerInputViewModel {
|
extension CustomEmojiPickerInputViewModel {
|
||||||
|
|
||||||
private func removeEmptyReferences() {
|
private func removeEmptyReferences() {
|
||||||
customEmojiReplacableTextInputReferences.removeAll(where: { element in
|
customEmojiReplaceableTextInputReferences.removeAll(where: { element in
|
||||||
element.value == nil
|
element.value == nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func append(customEmojiReplacableTextInput textInput: CustomEmojiReplaceableTextInput) {
|
func append(customEmojiReplaceableTextInput textInput: CustomEmojiReplaceableTextInput) {
|
||||||
removeEmptyReferences()
|
removeEmptyReferences()
|
||||||
|
|
||||||
let isContains = customEmojiReplacableTextInputReferences.contains(where: { element in
|
let isContains = customEmojiReplaceableTextInputReferences.contains(where: { element in
|
||||||
element.value === textInput
|
element.value === textInput
|
||||||
})
|
})
|
||||||
guard !isContains else {
|
guard !isContains else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
customEmojiReplacableTextInputReferences.append(CustomEmojiReplacableTextInputReference(value: textInput))
|
customEmojiReplaceableTextInputReferences.append(CustomEmojiReplaceableTextInputReference(value: textInput))
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertText(_ text: String) -> CustomEmojiReplacableTextInputReference? {
|
func insertText(_ text: String) -> CustomEmojiReplaceableTextInputReference? {
|
||||||
removeEmptyReferences()
|
removeEmptyReferences()
|
||||||
|
|
||||||
for reference in customEmojiReplacableTextInputReferences {
|
for reference in customEmojiReplaceableTextInputReferences {
|
||||||
guard reference.value?.isFirstResponder == true else { continue }
|
guard reference.value?.isFirstResponder == true else { continue }
|
||||||
reference.value?.insertText(text)
|
reference.value?.insertText(text)
|
||||||
return reference
|
return reference
|
||||||
|
|
|
@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadMiddleState {
|
||||||
stateMachine.enter(Fail.self)
|
stateMachine.enter(Fail.self)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
|
_ = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
|
||||||
status.id
|
status.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,8 @@ import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
import FLEX
|
||||||
|
|
||||||
extension HomeTimelineViewController {
|
extension HomeTimelineViewController {
|
||||||
var debugMenu: UIMenu {
|
var debugMenu: UIMenu {
|
||||||
let menu = UIMenu(
|
let menu = UIMenu(
|
||||||
|
@ -19,6 +21,10 @@ extension HomeTimelineViewController {
|
||||||
identifier: nil,
|
identifier: nil,
|
||||||
options: .displayInline,
|
options: .displayInline,
|
||||||
children: [
|
children: [
|
||||||
|
UIAction(title: "Show FLEX", image: nil, attributes: [], handler: { [weak self] action in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.showFLEXAction(action)
|
||||||
|
}),
|
||||||
moveMenu,
|
moveMenu,
|
||||||
dropMenu,
|
dropMenu,
|
||||||
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
|
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
|
||||||
|
@ -115,6 +121,10 @@ extension HomeTimelineViewController {
|
||||||
|
|
||||||
extension HomeTimelineViewController {
|
extension HomeTimelineViewController {
|
||||||
|
|
||||||
|
@objc private func showFLEXAction(_ sender: UIAction) {
|
||||||
|
FLEXManager.shared.showExplorer()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func moveToTopGapAction(_ sender: UIAction) {
|
@objc private func moveToTopGapAction(_ sender: UIAction) {
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||||
|
|
|
@ -9,6 +9,7 @@ import os.log
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import GameController
|
import GameController
|
||||||
|
import AuthenticationServices
|
||||||
|
|
||||||
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
|
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
|
@ -19,6 +20,11 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
||||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
var viewModel: MastodonPickServerViewModel!
|
var viewModel: MastodonPickServerViewModel!
|
||||||
|
private(set) lazy var authenticationViewModel = AuthenticationViewModel(
|
||||||
|
context: context,
|
||||||
|
coordinator: coordinator,
|
||||||
|
isAuthenticationExist: false
|
||||||
|
)
|
||||||
|
|
||||||
private var expandServerDomainSet = Set<String>()
|
private var expandServerDomainSet = Set<String>()
|
||||||
|
|
||||||
|
@ -50,6 +56,8 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
||||||
}()
|
}()
|
||||||
var nextStepButtonBottomLayoutConstraint: NSLayoutConstraint!
|
var nextStepButtonBottomLayoutConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
|
var mastodonAuthenticationController: MastodonAuthenticationController?
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
tableViewObservation = nil
|
tableViewObservation = nil
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
@ -182,23 +190,26 @@ extension MastodonPickServerViewController {
|
||||||
.assign(to: \.isEnabled, on: nextStepButton)
|
.assign(to: \.isEnabled, on: nextStepButton)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
viewModel.error
|
Publishers.Merge(
|
||||||
.compactMap { $0 }
|
viewModel.error,
|
||||||
.receive(on: DispatchQueue.main)
|
authenticationViewModel.error
|
||||||
.sink { [weak self] error in
|
)
|
||||||
guard let self = self else { return }
|
.compactMap { $0 }
|
||||||
let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert)
|
.receive(on: DispatchQueue.main)
|
||||||
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
.sink { [weak self] error in
|
||||||
alertController.addAction(okAction)
|
guard let self = self else { return }
|
||||||
self.coordinator.present(
|
let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert)
|
||||||
scene: .alertController(alertController: alertController),
|
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||||
from: nil,
|
alertController.addAction(okAction)
|
||||||
transition: .alertController(animated: true, completion: nil)
|
self.coordinator.present(
|
||||||
)
|
scene: .alertController(alertController: alertController),
|
||||||
}
|
from: nil,
|
||||||
.store(in: &disposeBag)
|
transition: .alertController(animated: true, completion: nil)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
viewModel
|
authenticationViewModel
|
||||||
.authenticated
|
.authenticated
|
||||||
.flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in
|
.flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in
|
||||||
guard let self = self else { return Just(.success(false)).eraseToAnyPublisher() }
|
guard let self = self else { return Just(.success(false)).eraseToAnyPublisher() }
|
||||||
|
@ -217,7 +228,7 @@ extension MastodonPickServerViewController {
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
viewModel.isAuthenticating
|
authenticationViewModel.isAuthenticating
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] isAuthenticating in
|
.sink { [weak self] isAuthenticating in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
@ -273,11 +284,15 @@ extension MastodonPickServerViewController {
|
||||||
|
|
||||||
private func doSignIn() {
|
private func doSignIn() {
|
||||||
guard let server = viewModel.selectedServer.value else { return }
|
guard let server = viewModel.selectedServer.value else { return }
|
||||||
viewModel.isAuthenticating.send(true)
|
authenticationViewModel.isAuthenticating.send(true)
|
||||||
context.apiService.createApplication(domain: server.domain)
|
context.apiService.createApplication(domain: server.domain)
|
||||||
.tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in
|
.tryMap { response -> AuthenticationViewModel.AuthenticateInfo in
|
||||||
let application = response.value
|
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)
|
throw APIService.APIError.explicit(.badResponse)
|
||||||
}
|
}
|
||||||
return info
|
return info
|
||||||
|
@ -285,7 +300,7 @@ extension MastodonPickServerViewController {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] completion in
|
.sink { [weak self] completion in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.viewModel.isAuthenticating.send(false)
|
self.authenticationViewModel.isAuthenticating.send(false)
|
||||||
|
|
||||||
switch completion {
|
switch completion {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
@ -296,15 +311,19 @@ extension MastodonPickServerViewController {
|
||||||
}
|
}
|
||||||
} receiveValue: { [weak self] info in
|
} receiveValue: { [weak self] info in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.authorizeURL)
|
let authenticationController = MastodonAuthenticationController(
|
||||||
self.viewModel.authenticate(
|
context: self.context,
|
||||||
info: info,
|
authenticateURL: info.authorizeURL
|
||||||
pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher
|
|
||||||
)
|
)
|
||||||
self.viewModel.mastodonPinBasedAuthenticationViewController = self.coordinator.present(
|
|
||||||
scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel),
|
self.mastodonAuthenticationController = authenticationController
|
||||||
from: nil,
|
authenticationController.authenticationSession?.prefersEphemeralWebBrowserSession = true
|
||||||
transition: .modal(animated: true, completion: nil)
|
authenticationController.authenticationSession?.presentationContextProvider = self
|
||||||
|
authenticationController.authenticationSession?.start()
|
||||||
|
|
||||||
|
self.authenticationViewModel.authenticate(
|
||||||
|
info: info,
|
||||||
|
pinCodePublisher: authenticationController.pinCodePublisher
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
@ -313,7 +332,7 @@ extension MastodonPickServerViewController {
|
||||||
private func doSignUp() {
|
private func doSignUp() {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
guard let server = viewModel.selectedServer.value else { return }
|
guard let server = viewModel.selectedServer.value else { return }
|
||||||
viewModel.isAuthenticating.send(true)
|
authenticationViewModel.isAuthenticating.send(true)
|
||||||
|
|
||||||
context.apiService.instance(domain: server.domain)
|
context.apiService.instance(domain: server.domain)
|
||||||
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseFirst, Error>? in
|
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseFirst, Error>? in
|
||||||
|
@ -328,7 +347,10 @@ extension MastodonPickServerViewController {
|
||||||
.switchToLatest()
|
.switchToLatest()
|
||||||
.tryMap { response -> MastodonPickServerViewModel.SignUpResponseSecond in
|
.tryMap { response -> MastodonPickServerViewModel.SignUpResponseSecond in
|
||||||
let application = response.application.value
|
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)
|
throw APIService.APIError.explicit(.badResponse)
|
||||||
}
|
}
|
||||||
return MastodonPickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo)
|
return MastodonPickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo)
|
||||||
|
@ -340,7 +362,8 @@ extension MastodonPickServerViewController {
|
||||||
return self.context.apiService.applicationAccessToken(
|
return self.context.apiService.applicationAccessToken(
|
||||||
domain: server.domain,
|
domain: server.domain,
|
||||||
clientID: authenticateInfo.clientID,
|
clientID: authenticateInfo.clientID,
|
||||||
clientSecret: authenticateInfo.clientSecret
|
clientSecret: authenticateInfo.clientSecret,
|
||||||
|
redirectURI: authenticateInfo.redirectURI
|
||||||
)
|
)
|
||||||
.map { MastodonPickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
|
.map { MastodonPickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -349,7 +372,7 @@ extension MastodonPickServerViewController {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] completion in
|
.sink { [weak self] completion in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.viewModel.isAuthenticating.send(false)
|
self.authenticationViewModel.isAuthenticating.send(false)
|
||||||
|
|
||||||
switch completion {
|
switch completion {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
@ -519,3 +542,10 @@ extension MastodonPickServerViewController: PickServerCellDelegate {
|
||||||
|
|
||||||
// MARK: - OnboardingViewControllerAppearance
|
// MARK: - OnboardingViewControllerAppearance
|
||||||
extension MastodonPickServerViewController: 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 filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||||
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
|
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
|
||||||
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
|
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
|
||||||
let error = PassthroughSubject<Error, Never>()
|
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||||
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
|
|
||||||
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
|
|
||||||
|
|
||||||
let isLoadingIndexedServers = CurrentValueSubject<Bool, Never>(false)
|
let isLoadingIndexedServers = CurrentValueSubject<Bool, Never>(false)
|
||||||
let emptyStateViewState = CurrentValueSubject<EmptyStateViewState, Never>(.none)
|
let emptyStateViewState = CurrentValueSubject<EmptyStateViewState, Never>(.none)
|
||||||
|
|
||||||
var mastodonPinBasedAuthenticationViewController: UIViewController?
|
|
||||||
|
|
||||||
init(context: AppContext, mode: PickServerMode) {
|
init(context: AppContext, mode: PickServerMode) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.mode = mode
|
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
|
// MARK: - SignUp methods & structs
|
||||||
extension MastodonPickServerViewModel {
|
extension MastodonPickServerViewModel {
|
||||||
|
|
|
@ -52,6 +52,8 @@ class PickServerSearchCell: UITableViewCell {
|
||||||
textField.clearButtonMode = .whileEditing
|
textField.clearButtonMode = .whileEditing
|
||||||
textField.autocapitalizationType = .none
|
textField.autocapitalizationType = .none
|
||||||
textField.autocorrectionType = .no
|
textField.autocorrectionType = .no
|
||||||
|
textField.returnKeyType = .done
|
||||||
|
textField.keyboardType = .URL
|
||||||
return textField
|
return textField
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -78,6 +80,7 @@ extension PickServerSearchCell {
|
||||||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
|
||||||
searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
|
searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
|
||||||
|
searchTextField.delegate = self
|
||||||
|
|
||||||
contentView.addSubview(bgView)
|
contentView.addSubview(bgView)
|
||||||
contentView.addSubview(textFieldBgView)
|
contentView.addSubview(textFieldBgView)
|
||||||
|
@ -107,3 +110,12 @@ extension PickServerSearchCell {
|
||||||
delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text)
|
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 isIdle = CurrentValueSubject<Bool, Never>(true)
|
||||||
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
|
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
|
||||||
let error = CurrentValueSubject<Error?, Never>(nil)
|
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||||
|
|
||||||
var mastodonPinBasedAuthenticationViewController: UIViewController?
|
|
||||||
|
|
||||||
init(context: AppContext, coordinator: SceneCoordinator, isAuthenticationExist: Bool) {
|
init(context: AppContext, coordinator: SceneCoordinator, isAuthenticationExist: Bool) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.coordinator = coordinator
|
self.coordinator = coordinator
|
||||||
|
@ -118,18 +116,24 @@ extension AuthenticationViewModel {
|
||||||
let clientID: String
|
let clientID: String
|
||||||
let clientSecret: String
|
let clientSecret: String
|
||||||
let authorizeURL: URL
|
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
|
self.domain = domain
|
||||||
guard let clientID = application.clientID,
|
guard let clientID = application.clientID,
|
||||||
let clientSecret = application.clientSecret else { return nil }
|
let clientSecret = application.clientSecret else { return nil }
|
||||||
self.clientID = clientID
|
self.clientID = clientID
|
||||||
self.clientSecret = clientSecret
|
self.clientSecret = clientSecret
|
||||||
self.authorizeURL = {
|
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)
|
let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query)
|
||||||
return url
|
return url
|
||||||
}()
|
}()
|
||||||
|
self.redirectURI = redirectURI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,8 +142,6 @@ extension AuthenticationViewModel {
|
||||||
.handleEvents(receiveOutput: { [weak self] _ in
|
.handleEvents(receiveOutput: { [weak self] _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.isAuthenticating.value = true
|
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
|
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
|
||||||
guard let self = self else { return nil }
|
guard let self = self else { return nil }
|
||||||
|
@ -148,6 +150,7 @@ extension AuthenticationViewModel {
|
||||||
domain: info.domain,
|
domain: info.domain,
|
||||||
clientID: info.clientID,
|
clientID: info.clientID,
|
||||||
clientSecret: info.clientSecret,
|
clientSecret: info.clientSecret,
|
||||||
|
redirectURI: info.redirectURI,
|
||||||
code: code
|
code: code
|
||||||
)
|
)
|
||||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
.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 UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
import ActiveLabel
|
||||||
import AlamofireImage
|
import AlamofireImage
|
||||||
import CropViewController
|
import CropViewController
|
||||||
import TwitterTextEditor
|
import TwitterTextEditor
|
||||||
|
@ -16,6 +17,7 @@ import TwitterTextEditor
|
||||||
protocol ProfileHeaderViewControllerDelegate: AnyObject {
|
protocol ProfileHeaderViewControllerDelegate: AnyObject {
|
||||||
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
|
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
|
||||||
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int)
|
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 {
|
final class ProfileHeaderViewController: UIViewController {
|
||||||
|
@ -96,6 +98,15 @@ extension ProfileHeaderViewController {
|
||||||
])
|
])
|
||||||
profileHeaderView.preservesSuperviewLayoutMargins = true
|
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
|
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(pageSegmentedControl)
|
view.addSubview(pageSegmentedControl)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -190,6 +201,17 @@ extension ProfileHeaderViewController {
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.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.menu = createAvatarContextMenu()
|
||||||
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
|
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
|
||||||
}
|
}
|
||||||
|
@ -198,14 +220,6 @@ extension ProfileHeaderViewController {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
viewModel.viewDidAppear.value = true
|
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() {
|
override func viewDidLayoutSubviews() {
|
||||||
|
@ -265,6 +279,48 @@ extension ProfileHeaderViewController {
|
||||||
delegate?.profileHeaderViewController(self, pageSegmentedControlValueChanged: sender, selectedSegmentIndex: sender.selectedSegmentIndex)
|
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 {
|
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)
|
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
|
||||||
updateHeaderBottomShadow(progress: progress)
|
updateHeaderBottomShadow(progress: progress)
|
||||||
|
|
||||||
|
@ -336,12 +392,12 @@ extension ProfileHeaderViewController {
|
||||||
viewModel.isTitleViewContentOffsetSet.value = true
|
viewModel.isTitleViewContentOffsetSet.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// set avatar
|
// set avatar fade
|
||||||
if progress > 0 {
|
if progress > 0 {
|
||||||
setProfileBannerFade(alpha: 0)
|
setProfileBannerFade(alpha: 0)
|
||||||
} else if progress > -0.3 {
|
} else if progress > -abs(throttle) {
|
||||||
// y = -(10/3)x
|
// y = -(1/0.8T)x
|
||||||
let alpha = -10.0 / 3.0 * progress
|
let alpha = -1 / abs(0.8 * throttle) * progress
|
||||||
setProfileBannerFade(alpha: alpha)
|
setProfileBannerFade(alpha: alpha)
|
||||||
} else {
|
} else {
|
||||||
setProfileBannerFade(alpha: 1)
|
setProfileBannerFade(alpha: 1)
|
||||||
|
@ -384,9 +440,9 @@ extension ProfileHeaderViewController: PHPickerViewControllerDelegate {
|
||||||
case .finished:
|
case .finished:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} receiveValue: { [weak self] imageData in
|
} receiveValue: { [weak self] file in
|
||||||
guard let self = self else { return }
|
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 }
|
guard let image = UIImage(data: imageData) else { return }
|
||||||
self.cropImage(image: image, pickerViewController: picker)
|
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.
|
// Created by MainasuK Cirno on 2021-4-9.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import Kanna
|
import Kanna
|
||||||
|
@ -12,6 +13,8 @@ import MastodonSDK
|
||||||
|
|
||||||
final class ProfileHeaderViewModel {
|
final class ProfileHeaderViewModel {
|
||||||
|
|
||||||
|
static let maxProfileFieldCount = 4
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
// input
|
// input
|
||||||
|
@ -20,11 +23,13 @@ final class ProfileHeaderViewModel {
|
||||||
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
|
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
|
||||||
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
|
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
|
||||||
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false)
|
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let emojiDict = CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>([:])
|
||||||
|
|
||||||
// output
|
// output
|
||||||
let displayProfileInfo = ProfileInfo()
|
let displayProfileInfo = ProfileInfo()
|
||||||
let editProfileInfo = ProfileInfo()
|
let editProfileInfo = ProfileInfo()
|
||||||
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false)
|
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
var fieldDiffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>!
|
||||||
|
|
||||||
init(context: AppContext) {
|
init(context: AppContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
|
@ -35,11 +40,64 @@ final class ProfileHeaderViewModel {
|
||||||
.sink { [weak self] isEditing in
|
.sink { [weak self] isEditing in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// setup editing value when toggle to editing
|
// setup editing value when toggle to editing
|
||||||
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
|
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
|
||||||
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
|
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
|
||||||
self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value)
|
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)
|
.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 name = CurrentValueSubject<String?, Never>(nil)
|
||||||
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
|
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
|
||||||
let note = CurrentValueSubject<String?, Never>(nil)
|
let note = CurrentValueSubject<String?, Never>(nil)
|
||||||
|
let fields = CurrentValueSubject<[ProfileFieldItem.FieldValue], Never>([])
|
||||||
|
|
||||||
enum ImageResource {
|
enum ImageResource {
|
||||||
case url(URL?)
|
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 {
|
extension ProfileHeaderViewModel {
|
||||||
|
|
||||||
static func normalize(note: String?) -> String? {
|
static func normalize(note: String?) -> String? {
|
||||||
|
@ -75,6 +151,19 @@ extension ProfileHeaderViewModel {
|
||||||
guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true }
|
guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true }
|
||||||
guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil 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 }
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
@ -95,6 +184,10 @@ extension ProfileHeaderViewModel {
|
||||||
return image
|
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(
|
let query = Mastodon.API.Account.UpdateCredentialQuery(
|
||||||
discoverable: nil,
|
discoverable: nil,
|
||||||
bot: nil,
|
bot: nil,
|
||||||
|
@ -104,7 +197,7 @@ extension ProfileHeaderViewModel {
|
||||||
header: nil,
|
header: nil,
|
||||||
locked: nil,
|
locked: nil,
|
||||||
source: nil,
|
source: nil,
|
||||||
fieldsAttributes: nil // TODO:
|
fieldsAttributes: fieldsAttributes
|
||||||
)
|
)
|
||||||
return context.apiService.accountUpdateCredentials(
|
return context.apiService.accountUpdateCredentials(
|
||||||
domain: domain,
|
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 UIKit
|
||||||
|
import Combine
|
||||||
import ActiveLabel
|
import ActiveLabel
|
||||||
|
|
||||||
final class ProfileFieldView: UIView {
|
final class ProfileFieldView: UIView {
|
||||||
|
|
||||||
let titleLabel: UILabel = {
|
var disposeBag = Set<AnyCancellable>()
|
||||||
let label = UILabel()
|
|
||||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold))
|
// output
|
||||||
label.textColor = Asset.Colors.Label.primary.color
|
let name = PassthroughSubject<String, Never>()
|
||||||
label.text = "Title"
|
let value = PassthroughSubject<String, Never>()
|
||||||
|
|
||||||
|
// for custom emoji display
|
||||||
|
let titleActiveLabel: ActiveLabel = {
|
||||||
|
let label = ActiveLabel(style: .profileFieldName)
|
||||||
|
label.configure(content: "title", emojiDict: [:])
|
||||||
return label
|
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 valueActiveLabel: ActiveLabel = {
|
||||||
let label = ActiveLabel(style: .profileField)
|
let label = ActiveLabel(style: .profileFieldValue)
|
||||||
label.configure(content: "value", emojiDict: [:])
|
label.configure(content: "value", emojiDict: [:])
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let topSeparatorLine = UIView.separatorLine
|
// for editing
|
||||||
let bottomSeparatorLine = UIView.separatorLine
|
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) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
@ -41,42 +64,67 @@ final class ProfileFieldView: UIView {
|
||||||
|
|
||||||
extension ProfileFieldView {
|
extension ProfileFieldView {
|
||||||
private func _init() {
|
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([
|
NSLayoutConstraint.activate([
|
||||||
titleLabel.topAnchor.constraint(equalTo: topAnchor),
|
containerStackView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
|
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
|
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
titleLabel.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
|
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
|
valueActiveLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addSubview(valueActiveLabel)
|
containerStackView.addArrangedSubview(valueActiveLabel)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
valueActiveLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
valueActiveLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||||
valueActiveLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
|
|
||||||
valueActiveLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
|
|
||||||
])
|
])
|
||||||
valueActiveLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
valueActiveLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||||
|
valueTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||||
topSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
|
containerStackView.addArrangedSubview(valueTextField)
|
||||||
addSubview(topSeparatorLine)
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
topSeparatorLine.topAnchor.constraint(equalTo: topAnchor),
|
valueTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||||
topSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
topSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
topSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
|
titleTextField.isHidden = true
|
||||||
addSubview(bottomSeparatorLine)
|
valueTextField.isHidden = true
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
|
NotificationCenter.default
|
||||||
bottomSeparatorLine.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
|
.publisher(for: UITextField.textDidChangeNotification, object: titleTextField)
|
||||||
bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
|
.receive(on: DispatchQueue.main)
|
||||||
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
|
.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 {
|
static var previews: some View {
|
||||||
UIViewPreview(width: 375) {
|
UIViewPreview(width: 375) {
|
||||||
let filedView = ProfileFieldView()
|
let filedView = ProfileFieldView()
|
||||||
filedView.valueActiveLabel.configure(field: "https://mastodon.online")
|
filedView.valueActiveLabel.configure(field: "https://mastodon.online", emojiDict: [:])
|
||||||
return filedView
|
return filedView
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 375, height: 100))
|
.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, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
|
||||||
|
|
||||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
|
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
|
||||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView)
|
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView)
|
||||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView)
|
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ProfileHeaderView: UIView {
|
final class ProfileHeaderView: UIView {
|
||||||
|
@ -80,6 +80,7 @@ final class ProfileHeaderView: UIView {
|
||||||
view.layer.masksToBounds = true
|
view.layer.masksToBounds = true
|
||||||
view.layer.cornerCurve = .continuous
|
view.layer.cornerCurve = .continuous
|
||||||
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
|
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
|
||||||
|
view.alpha = 0 // set initial state invisible
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -152,6 +153,38 @@ final class ProfileHeaderView: UIView {
|
||||||
return textEditorView
|
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) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
_init()
|
_init()
|
||||||
|
@ -162,6 +195,10 @@ final class ProfileHeaderView: UIView {
|
||||||
_init()
|
_init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
fieldCollectionViewHeightObservation = nil
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileHeaderView {
|
extension ProfileHeaderView {
|
||||||
|
@ -193,24 +230,24 @@ extension ProfileHeaderView {
|
||||||
])
|
])
|
||||||
|
|
||||||
// avatar
|
// avatar
|
||||||
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
bannerContainerView.addSubview(avatarImageView)
|
bannerContainerView.addSubview(avatarImageViewBackgroundView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
avatarImageView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
|
avatarImageViewBackgroundView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
|
||||||
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20),
|
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageViewBackgroundView.bottomAnchor, constant: 20),
|
||||||
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
|
|
||||||
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView)
|
avatarImageViewBackgroundView.addSubview(avatarImageView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||||
avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, 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.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||||
avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, 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
|
editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
avatarImageView.addSubview(editAvatarBackgroundView)
|
avatarImageView.addSubview(editAvatarBackgroundView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -328,8 +365,20 @@ extension ProfileHeaderView {
|
||||||
bioContainerStackView.addArrangedSubview(bioActiveLabelContainer)
|
bioContainerStackView.addArrangedSubview(bioActiveLabelContainer)
|
||||||
bioContainerStackView.addArrangedSubview(bioTextEditorView)
|
bioContainerStackView.addArrangedSubview(bioTextEditorView)
|
||||||
|
|
||||||
fieldContainerStackView.preservesSuperviewLayoutMargins = true
|
fieldCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
metaContainerStackView.addSubview(fieldContainerStackView)
|
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(bannerContainerView)
|
||||||
bringSubviewToFront(nameContainerStackView)
|
bringSubviewToFront(nameContainerStackView)
|
||||||
|
|
|
@ -368,6 +368,18 @@ extension ProfileViewController {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
|
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
|
||||||
.store(in: &disposeBag)
|
.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
|
viewModel.username
|
||||||
.map { username in username.flatMap { "@" + $0 } ?? " " }
|
.map { username in username.flatMap { "@" + $0 } ?? " " }
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
|
@ -640,7 +652,8 @@ extension ProfileViewController: UIScrollViewDelegate {
|
||||||
|
|
||||||
// elastically banner image
|
// elastically banner image
|
||||||
let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY
|
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
|
// MARK: - ProfilePagingViewControllerDelegate
|
||||||
|
@ -852,20 +878,27 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||||
case .url(_, _, let url, _):
|
case .url(_, _, let url, _):
|
||||||
guard let url = URL(string: url) else { return }
|
guard let url = URL(string: url) else { return }
|
||||||
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
|
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:
|
default:
|
||||||
// TODO:
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
|
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 statusesCount: CurrentValueSubject<Int?, Never>
|
||||||
let followingCount: CurrentValueSubject<Int?, Never>
|
let followingCount: CurrentValueSubject<Int?, Never>
|
||||||
let followersCount: 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 protected: CurrentValueSubject<Bool?, Never>
|
||||||
let suspended: 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.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
|
||||||
self.protected = CurrentValueSubject(mastodonUser?.locked)
|
self.protected = CurrentValueSubject(mastodonUser?.locked)
|
||||||
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
|
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
|
||||||
|
self.fileds = CurrentValueSubject(mastodonUser?.fields ?? [])
|
||||||
|
self.emojiDict = CurrentValueSubject(mastodonUser?.emojiDict ?? [:])
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
relationshipActionOptionSet
|
relationshipActionOptionSet
|
||||||
|
@ -231,6 +235,8 @@ extension ProfileViewModel {
|
||||||
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
|
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
|
||||||
self.protected.value = mastodonUser?.locked
|
self.protected.value = mastodonUser?.locked
|
||||||
self.suspended.value = mastodonUser?.suspended ?? false
|
self.suspended.value = mastodonUser?.suspended ?? false
|
||||||
|
self.fileds.value = mastodonUser?.fields ?? []
|
||||||
|
self.emojiDict.value = mastodonUser?.emojiDict ?? [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
|
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
|
||||||
|
|
|
@ -59,11 +59,6 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
|
||||||
|
|
||||||
var items = [Item]()
|
var items = [Item]()
|
||||||
for (_, status) in indexStatusTuples {
|
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()
|
let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute()
|
||||||
items.append(Item.status(objectID: status.objectID, attribute: attribute))
|
items.append(Item.status(objectID: status.objectID, attribute: attribute))
|
||||||
if statusIDsWhichHasGap.contains(status.id) {
|
if statusIDsWhichHasGap.contains(status.id) {
|
||||||
|
|
|
@ -12,8 +12,7 @@ import ActiveLabel
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
import AlamofireImage
|
|
||||||
import Kingfisher
|
|
||||||
|
|
||||||
class SettingsViewController: UIViewController, NeedsDependency {
|
class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
|
@ -95,7 +94,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
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(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self))
|
||||||
tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self))
|
tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self))
|
||||||
|
@ -186,7 +185,14 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupView() {
|
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()
|
setupNavigation()
|
||||||
|
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
|
@ -319,36 +325,44 @@ extension SettingsViewController: UITableViewDelegate {
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
guard let dataSource = viewModel.dataSource else { return }
|
guard let dataSource = viewModel.dataSource else { return }
|
||||||
let item = dataSource.itemIdentifier(for: indexPath)
|
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
|
||||||
switch item {
|
switch item {
|
||||||
case .boringZone:
|
case .apperance:
|
||||||
guard let url = viewModel.privacyURL else { break }
|
// do nothing
|
||||||
coordinator.present(
|
break
|
||||||
scene: .safari(url: url),
|
case .notification:
|
||||||
from: self,
|
// do nothing
|
||||||
transition: .safariPresent(animated: true, completion: nil)
|
break
|
||||||
)
|
case .boringZone(let link), .spicyZone(let link):
|
||||||
case .spicyZone(let link):
|
switch link {
|
||||||
// clear media cache
|
case .termsOfService, .privacyPolicy:
|
||||||
if link.title == L10n.Scene.Settings.Section.Spicyzone.clear {
|
// same URL
|
||||||
// clean image cache for AlamofireImage
|
guard let url = viewModel.privacyURL else { break }
|
||||||
let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
coordinator.present(
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes)
|
scene: .safari(url: url),
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function)
|
from: self,
|
||||||
ImageDownloader.defaultURLCache().removeAllCachedResponses()
|
transition: .safariPresent(animated: true, completion: nil)
|
||||||
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)
|
case .clearMediaCache:
|
||||||
|
context.purgeCache()
|
||||||
// clean Kingfisher Cache
|
.receive(on: RunLoop.main)
|
||||||
KingfisherManager.shared.cache.clearDiskCache()
|
.sink { [weak self] byteCount in
|
||||||
}
|
guard let self = self else { return }
|
||||||
// logout
|
let byteCountformatted = AppContext.byteCountFormatter.string(fromByteCount: Int64(byteCount))
|
||||||
if link.title == L10n.Scene.Settings.Section.Spicyzone.signout {
|
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()
|
alertToSignout()
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -176,7 +176,7 @@ class SettingsAppearanceTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
// MARK: Private methods
|
// MARK: Private methods
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
backgroundColor = .clear
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
contentView.addSubview(stackView)
|
contentView.addSubview(stackView)
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,8 @@ class SettingsSectionHeader: UIView {
|
||||||
init(frame: CGRect, customView: UIView? = nil) {
|
init(frame: CGRect, customView: UIView? = nil) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
backgroundColor = .clear
|
||||||
|
|
||||||
stackView.addArrangedSubview(titleLabel)
|
stackView.addArrangedSubview(titleLabel)
|
||||||
if let view = customView {
|
if let view = customView {
|
||||||
stackView.addArrangedSubview(view)
|
stackView.addArrangedSubview(view)
|
||||||
|
|
|
@ -395,7 +395,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
||||||
let images = self.images.prefix(2)
|
let images = self.images.prefix(2)
|
||||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||||
for (i, mosiac) in mosaics.enumerated() {
|
for (i, mosiac) in mosaics.enumerated() {
|
||||||
let (imageView, blurhashOverlayImageView) = mosiac
|
let (imageView, _) = mosiac
|
||||||
imageView.image = images[i]
|
imageView.image = images[i]
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
|
@ -407,7 +407,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
||||||
let images = self.images.prefix(3)
|
let images = self.images.prefix(3)
|
||||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||||
for (i, mosiac) in mosaics.enumerated() {
|
for (i, mosiac) in mosaics.enumerated() {
|
||||||
let (imageView, blurhashOverlayImageView) = mosiac
|
let (imageView, _) = mosiac
|
||||||
imageView.image = images[i]
|
imageView.image = images[i]
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
|
@ -419,7 +419,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
||||||
let images = self.images.prefix(4)
|
let images = self.images.prefix(4)
|
||||||
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||||
for (i, mosiac) in mosaics.enumerated() {
|
for (i, mosiac) in mosaics.enumerated() {
|
||||||
let (imageView, blurhashOverlayImageView) = mosiac
|
let (imageView, _) = mosiac
|
||||||
imageView.image = images[i]
|
imageView.image = images[i]
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
|
|
|
@ -46,7 +46,7 @@ struct MosaicMeta {
|
||||||
let blurhash: String?
|
let blurhash: String?
|
||||||
let altText: 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> {
|
func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> {
|
||||||
return Future { promise in
|
return Future { promise in
|
||||||
|
|
|
@ -14,7 +14,7 @@ import UIKit
|
||||||
final class VideoPlayerViewModel {
|
final class VideoPlayerViewModel {
|
||||||
var disposeBag = Set<AnyCancellable>()
|
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
|
// input
|
||||||
let previewImageURL: URL?
|
let previewImageURL: URL?
|
||||||
let videoURL: URL
|
let videoURL: URL
|
||||||
|
|
|
@ -111,7 +111,7 @@ extension ThreadViewModel.LoadThreadState {
|
||||||
override func didEnter(from previousState: GKState?) {
|
override func didEnter(from previousState: GKState?) {
|
||||||
super.didEnter(from: previousState)
|
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) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||||
stateMachine.enter(Loading.self)
|
stateMachine.enter(Loading.self)
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,11 +163,18 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
||||||
var needsMaskWithAnimation = true
|
var needsMaskWithAnimation = true
|
||||||
let maskLayerToRect: CGRect? = {
|
let maskLayerToRect: CGRect? = {
|
||||||
guard case .mosaic = transitionItem.source else { return nil }
|
guard case .mosaic = transitionItem.source else { return nil }
|
||||||
guard let navigationBar = toVC.navigationController?.navigationBar else { return nil }
|
guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil }
|
||||||
let navigationBarFrameInWindow = toVC.view.convert(navigationBar.frame, to: nil)
|
let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil)
|
||||||
var rect = transitionMaskView.frame
|
|
||||||
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
|
|
||||||
|
|
||||||
|
// 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 {
|
if rect.minY < snapshot.frame.minY {
|
||||||
needsMaskWithAnimation = false
|
needsMaskWithAnimation = false
|
||||||
}
|
}
|
||||||
|
@ -177,8 +184,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
||||||
let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath
|
let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath
|
||||||
let maskLayerToFinalRect: CGRect? = {
|
let maskLayerToFinalRect: CGRect? = {
|
||||||
guard case .mosaic = transitionItem.source else { return nil }
|
guard case .mosaic = transitionItem.source else { return nil }
|
||||||
guard let tabBarController = toVC.tabBarController else { return nil }
|
guard let tabBarController = toVC.tabBarController, let tabBarSuperView = tabBarController.tabBar.superview else { return nil }
|
||||||
let tabBarFrameInWindow = toVC.view.convert(tabBarController.tabBar.frame, to: nil)
|
let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil)
|
||||||
var rect = maskLayerToRect ?? transitionMaskView.frame
|
var rect = maskLayerToRect ?? transitionMaskView.frame
|
||||||
let offset = rect.maxY - tabBarFrameInWindow.minY
|
let offset = rect.maxY - tabBarFrameInWindow.minY
|
||||||
guard offset > 0 else { return rect }
|
guard offset > 0 else { return rect }
|
||||||
|
@ -238,7 +245,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
||||||
|
|
||||||
private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
|
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 mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController,
|
||||||
let index = fromVC.pagingViewConttroller.currentIndex else {
|
let index = fromVC.pagingViewConttroller.currentIndex else {
|
||||||
fatalError()
|
fatalError()
|
||||||
|
@ -411,11 +418,18 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
||||||
var needsMaskWithAnimation = true
|
var needsMaskWithAnimation = true
|
||||||
let maskLayerToRect: CGRect? = {
|
let maskLayerToRect: CGRect? = {
|
||||||
guard case .mosaic = transitionItem.source else { return nil }
|
guard case .mosaic = transitionItem.source else { return nil }
|
||||||
guard let navigationBar = toVC.navigationController?.navigationBar else { return nil }
|
guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil }
|
||||||
let navigationBarFrameInWindow = toVC.view.convert(navigationBar.frame, to: nil)
|
let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil)
|
||||||
var rect = transitionMaskView.frame
|
|
||||||
rect.origin.y = navigationBarFrameInWindow.maxY
|
|
||||||
|
|
||||||
|
// 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 {
|
if rect.minY < snapshot.frame.minY {
|
||||||
needsMaskWithAnimation = false
|
needsMaskWithAnimation = false
|
||||||
}
|
}
|
||||||
|
@ -430,8 +444,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
||||||
|
|
||||||
let maskLayerToFinalRect: CGRect? = {
|
let maskLayerToFinalRect: CGRect? = {
|
||||||
guard case .mosaic = transitionItem.source else { return nil }
|
guard case .mosaic = transitionItem.source else { return nil }
|
||||||
guard let tabBarController = toVC.tabBarController else { return nil }
|
guard let tabBarController = toVC.tabBarController, let tabBarSuperView = tabBarController.tabBar.superview else { return nil }
|
||||||
let tabBarFrameInWindow = toVC.view.convert(tabBarController.tabBar.frame, to: nil)
|
let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil)
|
||||||
var rect = maskLayerToRect ?? transitionMaskView.frame
|
var rect = maskLayerToRect ?? transitionMaskView.frame
|
||||||
let offset = rect.maxY - tabBarFrameInWindow.minY
|
let offset = rect.maxY - tabBarFrameInWindow.minY
|
||||||
guard offset > 0 else { return rect }
|
guard offset > 0 else { return rect }
|
||||||
|
|
|
@ -20,7 +20,11 @@ extension APIService {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func createApplication(domain: String) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Application>, Error> {
|
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(
|
return Mastodon.API.App.create(
|
||||||
session: session,
|
session: session,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
|
|
|
@ -17,11 +17,13 @@ extension APIService {
|
||||||
domain: String,
|
domain: String,
|
||||||
clientID: String,
|
clientID: String,
|
||||||
clientSecret: String,
|
clientSecret: String,
|
||||||
|
redirectURI: String,
|
||||||
code: String
|
code: String
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
|
||||||
let query = Mastodon.API.OAuth.AccessTokenQuery(
|
let query = Mastodon.API.OAuth.AccessTokenQuery(
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
|
redirectURI: redirectURI,
|
||||||
code: code,
|
code: code,
|
||||||
grantType: "authorization_code"
|
grantType: "authorization_code"
|
||||||
)
|
)
|
||||||
|
@ -35,11 +37,13 @@ extension APIService {
|
||||||
func applicationAccessToken(
|
func applicationAccessToken(
|
||||||
domain: String,
|
domain: String,
|
||||||
clientID: String,
|
clientID: String,
|
||||||
clientSecret: String
|
clientSecret: String,
|
||||||
|
redirectURI: String
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
|
||||||
let query = Mastodon.API.OAuth.AccessTokenQuery(
|
let query = Mastodon.API.OAuth.AccessTokenQuery(
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
|
redirectURI: redirectURI,
|
||||||
code: nil,
|
code: nil,
|
||||||
grantType: "client_credentials"
|
grantType: "client_credentials"
|
||||||
)
|
)
|
||||||
|
|
|
@ -98,6 +98,8 @@ extension APIService.CoreData {
|
||||||
user.update(locked: property.locked)
|
user.update(locked: property.locked)
|
||||||
property.bot.flatMap { user.update(bot: $0) }
|
property.bot.flatMap { user.update(bot: $0) }
|
||||||
property.suspended.flatMap { user.update(suspended: $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)
|
user.didUpdate(at: networkDate)
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import os.log
|
||||||
|
|
||||||
final class AudioPlaybackService: NSObject {
|
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>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ final class EmojiService {
|
||||||
|
|
||||||
weak var apiService: APIService?
|
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] = [:]
|
private(set) var customEmojiViewModelDict: [String: CustomEmojiViewModel] = [:]
|
||||||
|
|
||||||
init(apiService: APIService) {
|
init(apiService: APIService) {
|
||||||
|
|
|
@ -104,7 +104,7 @@ extension Trie {
|
||||||
|
|
||||||
var values: NSSet {
|
var values: NSSet {
|
||||||
let valueSet = NSMutableSet(set: self.valueSet)
|
let valueSet = NSMutableSet(set: self.valueSet)
|
||||||
for (key, value) in children {
|
for (_, value) in children {
|
||||||
valueSet.addObjects(from: Array(value.values))
|
valueSet.addObjects(from: Array(value.values))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ extension MastodonAttachmentService.UploadState {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if service?.imageData.value != nil {
|
if service?.file.value != nil {
|
||||||
return stateClass == Uploading.self
|
return stateClass == Uploading.self
|
||||||
} else {
|
} else {
|
||||||
return stateClass == Fail.self
|
return stateClass == Fail.self
|
||||||
|
@ -53,15 +53,8 @@ extension MastodonAttachmentService.UploadState {
|
||||||
|
|
||||||
guard let service = service, let stateMachine = stateMachine else { return }
|
guard let service = service, let stateMachine = stateMachine else { return }
|
||||||
guard let authenticationBox = service.authenticationBox 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 description = service.description.value
|
||||||
let query = Mastodon.API.Media.UploadMeidaQuery(
|
let query = Mastodon.API.Media.UploadMeidaQuery(
|
||||||
file: file,
|
file: file,
|
||||||
|
@ -81,6 +74,7 @@ extension MastodonAttachmentService.UploadState {
|
||||||
case .failure(let error):
|
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)
|
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)
|
service.error.send(error)
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
case .finished:
|
case .finished:
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
break
|
break
|
||||||
|
|
|
@ -5,11 +5,13 @@
|
||||||
// Created by MainasuK Cirno on 2021-3-17.
|
// Created by MainasuK Cirno on 2021-3-17.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
import GameplayKit
|
import GameplayKit
|
||||||
|
import MobileCoreServices
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
protocol MastodonAttachmentServiceDelegate: AnyObject {
|
protocol MastodonAttachmentServiceDelegate: AnyObject {
|
||||||
|
@ -22,16 +24,16 @@ final class MastodonAttachmentService {
|
||||||
weak var delegate: MastodonAttachmentServiceDelegate?
|
weak var delegate: MastodonAttachmentServiceDelegate?
|
||||||
|
|
||||||
let identifier = UUID()
|
let identifier = UUID()
|
||||||
|
|
||||||
// input
|
// input
|
||||||
let context: AppContext
|
let context: AppContext
|
||||||
var authenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
var authenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||||
|
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
|
||||||
|
let description = CurrentValueSubject<String?, Never>(nil)
|
||||||
|
|
||||||
// output
|
// output
|
||||||
// TODO: handle video/GIF/Audio data
|
let thumbnailImage = CurrentValueSubject<UIImage?, Never>(nil)
|
||||||
let imageData = CurrentValueSubject<Data?, Never>(nil)
|
|
||||||
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
|
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
|
||||||
let description = CurrentValueSubject<String?, Never>(nil)
|
|
||||||
let error = CurrentValueSubject<Error?, Never>(nil)
|
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||||
|
|
||||||
private(set) lazy var uploadStateMachine: GKStateMachine = {
|
private(set) lazy var uploadStateMachine: GKStateMachine = {
|
||||||
|
@ -50,15 +52,24 @@ final class MastodonAttachmentService {
|
||||||
init(
|
init(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
pickerResult: PHPickerResult,
|
pickerResult: PHPickerResult,
|
||||||
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||||
) {
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.authenticationBox = initalAuthenticationBox
|
self.authenticationBox = initialAuthenticationBox
|
||||||
// end init
|
// end init
|
||||||
|
|
||||||
setupServiceObserver()
|
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
|
.sink { [weak self] completion in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
switch completion {
|
switch completion {
|
||||||
|
@ -68,9 +79,9 @@ final class MastodonAttachmentService {
|
||||||
case .finished:
|
case .finished:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} receiveValue: { [weak self] imageData in
|
} receiveValue: { [weak self] file in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.imageData.value = imageData
|
self.file.value = file
|
||||||
self.uploadStateMachine.enter(UploadState.Initial.self)
|
self.uploadStateMachine.enter(UploadState.Initial.self)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
@ -79,30 +90,49 @@ final class MastodonAttachmentService {
|
||||||
init(
|
init(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
image: UIImage,
|
image: UIImage,
|
||||||
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||||
) {
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.authenticationBox = initalAuthenticationBox
|
self.authenticationBox = initialAuthenticationBox
|
||||||
// end init
|
// end init
|
||||||
|
|
||||||
setupServiceObserver()
|
setupServiceObserver()
|
||||||
|
|
||||||
imageData.value = image.jpegData(compressionQuality: 0.75)
|
file.value = .jpeg(image.jpegData(compressionQuality: 0.75))
|
||||||
uploadStateMachine.enter(UploadState.Initial.self)
|
uploadStateMachine.enter(UploadState.Initial.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
imageData: Data,
|
documentURL: URL,
|
||||||
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
|
||||||
) {
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.authenticationBox = initalAuthenticationBox
|
self.authenticationBox = initialAuthenticationBox
|
||||||
// end init
|
// end init
|
||||||
|
|
||||||
setupServiceObserver()
|
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)
|
uploadStateMachine.enter(UploadState.Initial.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,6 +143,49 @@ final class MastodonAttachmentService {
|
||||||
self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state)
|
self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.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>()
|
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
|
// input
|
||||||
weak var apiService: APIService?
|
weak var apiService: APIService?
|
||||||
|
|
|
@ -16,7 +16,7 @@ final class StatusPrefetchingService {
|
||||||
|
|
||||||
typealias TaskID = String
|
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>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:]
|
private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:]
|
||||||
|
|
|
@ -16,7 +16,7 @@ final class StatusPublishService {
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
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
|
// input
|
||||||
var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models
|
var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models
|
||||||
|
|
|
@ -14,7 +14,7 @@ import os.log
|
||||||
final class VideoPlaybackService {
|
final class VideoPlaybackService {
|
||||||
var disposeBag = Set<AnyCancellable>()
|
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] = [:]
|
private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:]
|
||||||
|
|
||||||
// only for video kind
|
// only for video kind
|
||||||
|
|
|
@ -10,6 +10,8 @@ import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
import AlamofireImage
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
class AppContext: ObservableObject {
|
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 Combine
|
||||||
import MobileCoreServices
|
import MobileCoreServices
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
// load image with low memory usage
|
// load image with low memory usage
|
||||||
// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/
|
// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/
|
||||||
enum PHPickerResultLoader {
|
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
|
Future { promise in
|
||||||
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
|
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
|
@ -64,7 +65,36 @@ enum PHPickerResultLoader {
|
||||||
let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory)
|
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)
|
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.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
|
||||||
source.language.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
|
if let fieldsAttributes = fieldsAttributes {
|
||||||
for fieldsAttribute in fieldsAttributes {
|
if fieldsAttributes.isEmpty {
|
||||||
data.append(Data.multipart(key: "fields_attributes[name][]", value: fieldsAttribute.name))
|
data.append(Data.multipart(key: "fields_attributes[]", value: ""))
|
||||||
data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.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(
|
public init(
|
||||||
clientName: String,
|
clientName: String,
|
||||||
redirectURIs: String = "urn:ietf:wg:oauth:2.0:oob",
|
redirectURIs: String,
|
||||||
scopes: String? = "read write follow push",
|
scopes: String? = "read write follow push",
|
||||||
website: String?
|
website: String?
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -42,11 +42,17 @@ extension Mastodon.API.Media {
|
||||||
authorization: authorization
|
authorization: authorization
|
||||||
)
|
)
|
||||||
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
|
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)
|
return session.dataTaskPublisher(for: request)
|
||||||
.tryMap { data, response in
|
.tryMap { data, response in
|
||||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
||||||
return Mastodon.Response.Content(value: value, response: response)
|
return Mastodon.Response.Content(value: value, response: response)
|
||||||
}
|
}
|
||||||
|
.handleEvents(receiveCancel: {
|
||||||
|
// retain and handle cancel task
|
||||||
|
serialStream.boundStreams.output.close()
|
||||||
|
})
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,15 +79,30 @@ extension Mastodon.API.Media {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: Data? {
|
var body: Data? {
|
||||||
var data = Data()
|
// using stream data
|
||||||
|
return nil
|
||||||
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)) }
|
var serialStream: SerialStream {
|
||||||
focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) }
|
var streams: [InputStream] = []
|
||||||
|
|
||||||
data.append(Data.multipartEnd())
|
file.flatMap { value in
|
||||||
return data
|
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()
|
.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,
|
forceLogin: String? = nil,
|
||||||
responseType: String = "code",
|
responseType: String = "code",
|
||||||
clientID: String,
|
clientID: String,
|
||||||
redirectURI: String = "urn:ietf:wg:oauth:2.0:oob",
|
redirectURI: String,
|
||||||
scope: String? = "read write follow push"
|
scope: String? = "read write follow push"
|
||||||
) {
|
) {
|
||||||
self.forceLogin = forceLogin
|
self.forceLogin = forceLogin
|
||||||
|
@ -166,7 +166,7 @@ extension Mastodon.API.OAuth {
|
||||||
public init(
|
public init(
|
||||||
clientID: String,
|
clientID: String,
|
||||||
clientSecret: String,
|
clientSecret: String,
|
||||||
redirectURI: String = "urn:ietf:wg:oauth:2.0:oob",
|
redirectURI: String,
|
||||||
scope: String? = "read write follow push",
|
scope: String? = "read write follow push",
|
||||||
code: String?,
|
code: String?,
|
||||||
grantType: String
|
grantType: String
|
||||||
|
|
|
@ -27,5 +27,11 @@ extension Mastodon.Entity {
|
||||||
case value
|
case value
|
||||||
case verifiedAt = "verified_at"
|
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("Content-Type: \(contentType)\r\n".data(using: .utf8)!)
|
||||||
}
|
}
|
||||||
data.append("\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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,21 +16,22 @@ extension Mastodon.Query {
|
||||||
/// PNG (Portable Network Graphics) image
|
/// PNG (Portable Network Graphics) image
|
||||||
case png(Data?)
|
case png(Data?)
|
||||||
/// Other media file
|
/// Other media file
|
||||||
case other(Data?, fileExtension: String, mimeType: String)
|
/// e.g video
|
||||||
|
case other(URL?, fileExtension: String, mimeType: String)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mastodon.Query.MediaAttachment {
|
extension Mastodon.Query.MediaAttachment {
|
||||||
var data: Data? {
|
public var data: Data? {
|
||||||
switch self {
|
switch self {
|
||||||
case .jpeg(let data): return data
|
case .jpeg(let data): return data
|
||||||
case .gif(let data): return data
|
case .gif(let data): return data
|
||||||
case .png(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
|
let name = UUID().uuidString
|
||||||
switch self {
|
switch self {
|
||||||
case .jpeg: return "\(name).jpg"
|
case .jpeg: return "\(name).jpg"
|
||||||
|
@ -40,7 +41,7 @@ extension Mastodon.Query.MediaAttachment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var mimeType: String {
|
public var mimeType: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .jpeg: return "image/jpg"
|
case .jpeg: return "image/jpg"
|
||||||
case .gif: return "image/gif"
|
case .gif: return "image/gif"
|
||||||
|
@ -56,6 +57,14 @@ extension Mastodon.Query.MediaAttachment {
|
||||||
|
|
||||||
extension Mastodon.Query.MediaAttachment: MultipartFormValue {
|
extension Mastodon.Query.MediaAttachment: MultipartFormValue {
|
||||||
var multipartValue: Data { return data ?? Data() }
|
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 multipartContentType: String? { return mimeType }
|
||||||
var multipartFilename: String? { return fileName }
|
var multipartFilename: String? { return fileName }
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,10 +13,15 @@ enum Multipart {
|
||||||
|
|
||||||
protocol MultipartFormValue {
|
protocol MultipartFormValue {
|
||||||
var multipartValue: Data { get }
|
var multipartValue: Data { get }
|
||||||
|
var multipartStreamValue: InputStream? { get }
|
||||||
var multipartContentType: String? { get }
|
var multipartContentType: String? { get }
|
||||||
var multipartFilename: String? { get }
|
var multipartFilename: String? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MultipartFormValue {
|
||||||
|
var multipartStreamValue: InputStream? { nil }
|
||||||
|
}
|
||||||
|
|
||||||
extension Bool: MultipartFormValue {
|
extension Bool: MultipartFormValue {
|
||||||
var multipartValue: Data {
|
var multipartValue: Data {
|
||||||
switch self {
|
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>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.org.joinmastodon.mastodon-temp</string>
|
<string>group.org.joinmastodon.app</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
17
Podfile
17
Podfile
|
@ -13,6 +13,9 @@ target 'Mastodon' do
|
||||||
pod 'SwiftGen', '~> 6.4.0'
|
pod 'SwiftGen', '~> 6.4.0'
|
||||||
pod 'DateToolsSwift', '~> 5.0.0'
|
pod 'DateToolsSwift', '~> 5.0.0'
|
||||||
pod 'Kanna', '~> 5.2.2'
|
pod 'Kanna', '~> 5.2.2'
|
||||||
|
|
||||||
|
# DEBUG
|
||||||
|
pod 'FLEX', '~> 4.4.0', :configurations => ['Debug']
|
||||||
|
|
||||||
target 'MastodonTests' do
|
target 'MastodonTests' do
|
||||||
inherit! :search_paths
|
inherit! :search_paths
|
||||||
|
@ -23,14 +26,16 @@ target 'Mastodon' do
|
||||||
# Pods for testing
|
# Pods for testing
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'NotificationService' do
|
end
|
||||||
|
|
||||||
end
|
target 'NotificationService' do
|
||||||
|
# Comment the next line if you don't want to use dynamic frameworks
|
||||||
target 'AppShared' do
|
use_frameworks!
|
||||||
|
end
|
||||||
end
|
|
||||||
|
|
||||||
|
target 'AppShared' do
|
||||||
|
# Comment the next line if you don't want to use dynamic frameworks
|
||||||
|
use_frameworks!
|
||||||
end
|
end
|
||||||
|
|
||||||
plugin 'cocoapods-keys', {
|
plugin 'cocoapods-keys', {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
PODS:
|
PODS:
|
||||||
- DateToolsSwift (5.0.0)
|
- DateToolsSwift (5.0.0)
|
||||||
|
- FLEX (4.4.1)
|
||||||
- Kanna (5.2.4)
|
- Kanna (5.2.4)
|
||||||
- Keys (1.0.1)
|
- Keys (1.0.1)
|
||||||
- SwiftGen (6.4.0)
|
- SwiftGen (6.4.0)
|
||||||
|
@ -7,6 +8,7 @@ PODS:
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- DateToolsSwift (~> 5.0.0)
|
- DateToolsSwift (~> 5.0.0)
|
||||||
|
- FLEX (~> 4.4.0)
|
||||||
- Kanna (~> 5.2.2)
|
- Kanna (~> 5.2.2)
|
||||||
- Keys (from `Pods/CocoaPodsKeys`)
|
- Keys (from `Pods/CocoaPodsKeys`)
|
||||||
- SwiftGen (~> 6.4.0)
|
- SwiftGen (~> 6.4.0)
|
||||||
|
@ -15,6 +17,7 @@ DEPENDENCIES:
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- DateToolsSwift
|
- DateToolsSwift
|
||||||
|
- FLEX
|
||||||
- Kanna
|
- Kanna
|
||||||
- SwiftGen
|
- SwiftGen
|
||||||
- "UITextField+Shake"
|
- "UITextField+Shake"
|
||||||
|
@ -25,11 +28,12 @@ EXTERNAL SOURCES:
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
|
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
|
||||||
|
FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab
|
||||||
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
|
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
|
||||||
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
|
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
|
||||||
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
|
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
|
||||||
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
|
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
|
||||||
|
|
||||||
PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287
|
PODFILE CHECKSUM: 0daad1778e56099e7a7e7ebe3d292d20051840fa
|
||||||
|
|
||||||
COCOAPODS: 1.10.1
|
COCOAPODS: 1.10.1
|
||||||
|
|
Loading…
Reference in New Issue