Merge branch 'release/0.6.0'

This commit is contained in:
CMK 2021-06-15 11:57:58 +08:00
commit b190851056
89 changed files with 2382 additions and 731 deletions

View File

@ -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"
} }

View File

@ -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"

View File

@ -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"/>

View File

@ -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

View File

@ -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 }
} }

View File

@ -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",

View File

@ -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;

View File

@ -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>

View File

@ -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? {

View File

@ -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

View File

@ -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))
}
}
}

View File

@ -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)
} }

View File

@ -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)

View File

@ -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
}
}

View File

@ -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)

View File

@ -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
} }

View File

@ -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) }
}
}

View File

@ -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 { }

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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 %@

View File

@ -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]
} }
} }

View File

@ -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)

View File

@ -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>

View File

@ -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
} }

View File

@ -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 %@";

View File

@ -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 %@";

View File

@ -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 {

View File

@ -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

View File

@ -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?) {

View File

@ -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
}() }()

View File

@ -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)

View File

@ -84,3 +84,9 @@ extension CustomEmojiPickerInputView {
return layout return layout
} }
} }
extension CustomEmojiPickerInputView: UIInputViewAudioFeedback {
var enableInputClicksWhenVisible: Bool {
return true
}
}

View File

@ -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

View File

@ -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
} }

View File

@ -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()

View File

@ -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!
}
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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),
])
}
}

View File

@ -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))

View File

@ -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)

View File

@ -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) {
} }
} }

View File

@ -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?) {

View File

@ -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) {

View File

@ -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
} }
} }
} }

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
} }

View File

@ -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 }

View File

@ -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,

View File

@ -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"
) )

View File

@ -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)
} }

View File

@ -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>()

View File

@ -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) {

View File

@ -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))
} }

View File

@ -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

View File

@ -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()
}
}

View File

@ -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?

View File

@ -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] = [:]

View File

@ -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

View File

@ -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

View File

@ -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)
// }
}

View File

@ -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))
}
} }
} }
} }

View File

@ -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))
}
} }
} }

View File

@ -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?
) { ) {

View File

@ -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
}
}
} }

View File

@ -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

View File

@ -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
}
} }
} }

View File

@ -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
} }

View File

@ -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 }
} }

View File

@ -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 {

View File

@ -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()
}
}
}

View File

@ -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
View File

@ -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', {

View File

@ -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