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
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 static let keychain = Keychain(service: "org.joinmastodon.Mastodon.keychain", accessGroup: AppName.groupID)
public static let keychain = Keychain(service: "org.joinmastodon.app.keychain", accessGroup: AppName.groupID)
static let notificationPrivateKeyName = "notification-private-key-base64"
static let notificationAuthName = "notification-auth-base64"

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20E232" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20E241" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
@ -102,6 +102,7 @@
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojisData" optional="YES" attributeType="Binary"/>
<attribute name="fieldsData" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="String"/>
@ -273,7 +274,7 @@
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="689"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="704"/>
<element name="Mention" positionX="0" positionY="0" width="128" height="149"/>
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>

View File

@ -27,6 +27,7 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var url: String?
@NSManaged public private(set) var emojisData: Data?
@NSManaged public private(set) var fieldsData: Data?
@NSManaged public private(set) var statusesCount: NSNumber
@NSManaged public private(set) var followingCount: NSNumber
@ -92,6 +93,7 @@ extension MastodonUser {
user.note = property.note
user.url = property.url
user.emojisData = property.emojisData
user.fieldsData = property.fieldsData
user.statusesCount = NSNumber(value: property.statusesCount)
user.followingCount = NSNumber(value: property.followingCount)
@ -161,6 +163,11 @@ extension MastodonUser {
self.emojisData = emojisData
}
}
public func update(fieldsData: Data?) {
if self.fieldsData != fieldsData {
self.fieldsData = fieldsData
}
}
public func update(statusesCount: Int) {
if self.statusesCount.intValue != statusesCount {
self.statusesCount = NSNumber(value: statusesCount)
@ -281,6 +288,7 @@ extension MastodonUser {
public let note: String?
public let url: String?
public let emojisData: Data?
public let fieldsData: Data?
public let statusesCount: Int
public let followingCount: Int
public let followersCount: Int
@ -304,6 +312,7 @@ extension MastodonUser {
note: String?,
url: String?,
emojisData: Data?,
fieldsData: Data?,
statusesCount: Int,
followingCount: Int,
followersCount: Int,
@ -326,6 +335,7 @@ extension MastodonUser {
self.note = note
self.url = url
self.emojisData = emojisData
self.fieldsData = fieldsData
self.statusesCount = statusesCount
self.followingCount = followingCount
self.followersCount = followersCount

View File

@ -8,7 +8,7 @@
import Foundation
import CoreData
public protocol Managed: AnyObject, NSFetchRequestResult {
public protocol Managed: NSFetchRequestResult {
static var entityName: String { get }
static var defaultSortDescriptors: [NSSortDescriptor] { get }
}

View File

@ -21,7 +21,11 @@
},
"publish_post_failure": {
"title": "Publish Failure",
"message": "Failed to publish the post.\nPlease check your internet connection."
"message": "Failed to publish the post.\nPlease check your internet connection.",
"attchments_message": {
"video_attach_with_photo": "Cannot attach a video to a status that already contains images.",
"more_than_one_video": "Cannot attach more than one video."
}
},
"sign_out": {
"title": "Sign out",
@ -39,6 +43,10 @@
"delete_post": {
"title": "Are you sure you want to delete this post?",
"delete": "Delete"
},
"clean_cache": {
"title": "Clean Cache",
"message": "Successfully clean %s cache."
}
},
"controls": {
@ -165,6 +173,9 @@
"edit_info": "Edit info"
},
"timeline": {
"timestamp": {
"now": "Now"
},
"loader": {
"load_missing_posts": "Load missing posts",
"loading_missing_posts": "Loading missing posts...",
@ -404,6 +415,13 @@
"count_followers": "%ld followers"
}
},
"fields": {
"add_row": "Add Row",
"placeholder": {
"label": "Label",
"content": "Content"
}
},
"segmented_control": {
"posts": "Posts",
"replies": "Replies",

View File

@ -178,15 +178,12 @@
5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; };
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; };
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */; };
B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */; };
DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; };
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; };
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; };
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; };
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; };
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; };
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; };
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; };
DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */; };
@ -375,6 +372,7 @@
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; };
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */; };
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */; };
DB97131F2666078B00BD1E90 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB97131E2666078B00BD1E90 /* Date.swift */; };
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; };
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; };
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
@ -401,6 +399,12 @@
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; };
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; };
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */; };
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */; };
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */; };
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */; };
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94439265CC0FC00C537E1 /* Fields.swift */; };
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */; };
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */; };
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; };
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; };
@ -460,6 +464,9 @@
DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; };
DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; };
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */; };
DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */; };
EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -696,10 +703,10 @@
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = "<group>"; };
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = "<group>"; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = "<group>"; };
5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = "<group>"; };
5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = "<group>"; };
@ -733,19 +740,19 @@
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = "<group>"; };
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = "<group>"; };
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; };
79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.release.xcconfig"; sourceTree = "<group>"; };
9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = "<group>"; };
9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.debug.xcconfig"; sourceTree = "<group>"; };
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.release.xcconfig"; sourceTree = "<group>"; };
B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = "<group>"; };
BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = "<group>"; };
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = "<group>"; };
DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = "<group>"; };
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = "<group>"; };
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = "<group>"; };
DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = "<group>"; };
DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = "<group>"; };
DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCollectionView.swift; sourceTree = "<group>"; };
@ -936,6 +943,7 @@
DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+Provider.swift"; sourceTree = "<group>"; };
DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = "<group>"; };
DB97131E2666078B00BD1E90 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = "<group>"; };
DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = "<group>"; };
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = "<group>"; };
@ -961,6 +969,12 @@
DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = "<group>"; };
DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = "<group>"; };
DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = "<group>"; };
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldSection.swift; sourceTree = "<group>"; };
DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldItem.swift; sourceTree = "<group>"; };
DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBA94439265CC0FC00C537E1 /* Fields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fields.swift; sourceTree = "<group>"; };
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = "<group>"; };
DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Field.swift"; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = "<group>"; };
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = "<group>"; };
@ -1019,7 +1033,11 @@
DBF8AE15263293E400C9C23C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
DBF8AE17263293E400C9C23C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; };
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewHeaderFooterView.swift; sourceTree = "<group>"; };
DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldAddEntryCollectionViewCell.swift; sourceTree = "<group>"; };
EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = "<group>"; };
ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.release.xcconfig"; sourceTree = "<group>"; };
F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -1068,7 +1086,7 @@
buildActionMask = 2147483647;
files = (
DB6805102637D0F800430867 /* KeychainAccess in Frameworks */,
D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */,
EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1096,7 +1114,7 @@
DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */,
DBF8AE862632992800C9C23C /* Base85 in Frameworks */,
DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */,
7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */,
B914FC6B0B8AF18573C0B291 /* Pods_NotificationService.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1183,6 +1201,10 @@
B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */,
D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */,
B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */,
9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */,
ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */,
9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */,
9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@ -1402,6 +1424,7 @@
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */,
);
path = Section;
sourceTree = "<group>";
@ -1463,6 +1486,7 @@
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
DB6D9F8326358EEC008423CD /* SettingsItem.swift */,
DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */,
DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */,
);
path = Item;
sourceTree = "<group>";
@ -1526,8 +1550,8 @@
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */,
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */,
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */,
79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */,
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */,
F4A2A2D7000E477CA459ADA9 /* Pods_AppShared.framework */,
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -1590,7 +1614,6 @@
DB68A03825E900CC00CFDF14 /* Share */,
0FAA0FDD25E0B5700017CCDE /* Welcome */,
0FAA102525E1125D0017CCDE /* PickServer */,
DB0140A625C40C0900F9F3CF /* PinBasedAuthentication */,
DBE0821A25CD382900FD6BBD /* Register */,
DB72602125E36A2500235243 /* ServerRules */,
2D364F7025E66D5B00204FDC /* ResendEmail */,
@ -1599,16 +1622,6 @@
path = Onboarding;
sourceTree = "<group>";
};
DB0140A625C40C0900F9F3CF /* PinBasedAuthentication */ = {
isa = PBXGroup;
children = (
DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */,
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */,
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */,
);
path = PinBasedAuthentication;
sourceTree = "<group>";
};
DB084B5125CBC56300F898ED /* CoreDataStack */ = {
isa = PBXGroup;
children = (
@ -1619,6 +1632,7 @@
DB6D9F4826353FD6008423CD /* Subscription.swift */,
DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */,
DBAFB7342645463500371D5F /* Emojis.swift */,
DBA94439265CC0FC00C537E1 /* Fields.swift */,
);
path = CoreDataStack;
sourceTree = "<group>";
@ -1892,6 +1906,7 @@
children = (
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */,
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */,
DB029E94266A20430062874E /* MastodonAuthenticationController.swift */,
);
path = Share;
sourceTree = "<group>";
@ -1913,6 +1928,7 @@
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */,
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */,
DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */,
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */,
);
path = MastodonSDK;
@ -2139,6 +2155,7 @@
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
DB6D1B23263684C600ACB481 /* UserDefaults.swift */,
DB97131E2666078B00BD1E90 /* Date.swift */,
);
path = Extension;
sourceTree = "<group>";
@ -2345,6 +2362,7 @@
DBB525732612D5A5002F1F29 /* View */,
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */,
DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */,
);
path = Header;
sourceTree = "<group>";
@ -2357,6 +2375,9 @@
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */,
DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */,
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */,
);
path = View;
sourceTree = "<group>";
@ -2756,7 +2777,7 @@
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Mastodon-NotificationService-checkManifestLockResult.txt",
"$(DERIVED_FILE_DIR)/Pods-NotificationService-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
@ -2839,7 +2860,7 @@
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Mastodon-AppShared-checkManifestLockResult.txt",
"$(DERIVED_FILE_DIR)/Pods-AppShared-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
@ -2921,6 +2942,7 @@
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */,
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
@ -2992,6 +3014,7 @@
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */,
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
@ -3006,17 +3029,20 @@
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */,
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */,
DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */,
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */,
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */,
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
DB97131F2666078B00BD1E90 /* Date.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */,
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
@ -3045,6 +3071,7 @@
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */,
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */,
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */,
@ -3059,6 +3086,7 @@
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */,
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
@ -3108,13 +3136,13 @@
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */,
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
@ -3152,6 +3180,7 @@
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */,
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */,
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
@ -3177,7 +3206,6 @@
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */,
@ -3237,7 +3265,6 @@
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */,
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
@ -3261,6 +3288,7 @@
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */,
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */,
@ -3586,16 +3614,16 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon;
MARKETING_VERSION = 0.6.0;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -3613,16 +3641,16 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon;
MARKETING_VERSION = 0.6.0;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
@ -3636,7 +3664,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = MastodonTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -3657,7 +3685,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = MastodonTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -3677,7 +3705,7 @@
baseConfigurationReference = 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = MastodonUITests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -3697,7 +3725,7 @@
baseConfigurationReference = BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = MastodonUITests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -3714,14 +3742,14 @@
};
DB6804892637CD4C00430867 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */;
baseConfigurationReference = 9A0982D8F349244EB558CDFD /* Pods-AppShared.debug.xcconfig */;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -3732,7 +3760,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -3745,14 +3773,14 @@
};
DB68048A2637CD4C00430867 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */;
baseConfigurationReference = ECA373ABA86BE3C2D7ED878E /* Pods-AppShared.release.xcconfig */;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -3763,7 +3791,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.AppShared;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
@ -3780,7 +3808,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -3809,7 +3837,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -3835,7 +3863,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = CoreDataStackTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -3855,7 +3883,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 7LFDZ96332;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = CoreDataStackTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -3872,20 +3900,20 @@
};
DBF8AE1C263293E400C9C23C /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */;
baseConfigurationReference = 9553C689FFA9EBC880CAB78D /* Pods-NotificationService.debug.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = 7LFDZ96332;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService;
MARKETING_VERSION = 0.6.0;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
@ -3895,20 +3923,20 @@
};
DBF8AE1D263293E400C9C23C /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */;
baseConfigurationReference = 9776D7C4B79101CF70181127 /* Pods-NotificationService.release.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = 7LFDZ96332;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService;
MARKETING_VERSION = 0.6.0;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;

View File

@ -7,7 +7,7 @@
<key>AppShared.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>18</integer>
<integer>14</integer>
</dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
@ -27,12 +27,12 @@
<key>Mastodon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>13</integer>
</dict>
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>14</integer>
<integer>12</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -18,7 +18,7 @@ final class SafariActivity: UIActivity {
}
override var activityType: UIActivity.ActivityType? {
return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity")
return UIActivity.ActivityType("org.joinmastodon.app.safari-activity")
}
override var activityTitle: String? {

View File

@ -42,7 +42,6 @@ extension SceneCoordinator {
// onboarding
case welcome
case mastodonPickServer(viewMode: MastodonPickServerViewModel)
case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel)
case mastodonRegister(viewModel: MastodonRegisterViewModel)
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
@ -78,6 +77,7 @@ extension SceneCoordinator {
case safari(url: URL)
case alertController(alertController: UIAlertController)
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
#if DEBUG
case publicTimeline
#endif
@ -86,7 +86,6 @@ extension SceneCoordinator {
switch self {
case .welcome,
.mastodonPickServer,
.mastodonPinBasedAuthentication,
.mastodonRegister,
.mastodonServerRules,
.mastodonConfirmEmail,
@ -217,10 +216,6 @@ private extension SceneCoordinator {
let _viewController = MastodonPickServerViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonPinBasedAuthentication(let viewModel):
let _viewController = MastodonPinBasedAuthenticationViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonRegister(let viewModel):
let _viewController = MastodonRegisterViewController()
_viewController.viewModel = viewModel
@ -261,10 +256,6 @@ private extension SceneCoordinator {
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .settings(let viewModel):
let _viewController = SettingsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel

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 {
case .replyTo(let replyToStatusObjectID):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell
managedObjectContext.perform {
// set empty text before retrieve real data to fix pseudo-text display issue
cell.statusView.nameLabel.text = " "
cell.statusView.usernameLabel.text = " "
managedObjectContext.performAndWait {
guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
return
}
@ -73,7 +76,7 @@ extension ComposeStatusSection {
//status.emoji
cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
// set date
cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow
cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow
cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag)
}
@ -82,7 +85,7 @@ extension ComposeStatusSection {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value
cell.textEditorView.text = attribute.composeContent.value ?? ""
managedObjectContext.perform {
managedObjectContext.performAndWait {
guard let replyToStatusObjectID = replyToStatusObjectID,
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
cell.statusView.headerContainerView.isHidden = true
@ -98,21 +101,24 @@ extension ComposeStatusSection {
cell.composeContent
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { text in
.sink { [weak collectionView] text in
guard let collectionView = collectionView else { return }
// self size input cell
// needs restore content offset to resolve issue #83
let oldContentOffset = collectionView.contentOffset
collectionView.collectionViewLayout.invalidateLayout()
collectionView.layoutIfNeeded()
collectionView.contentOffset = oldContentOffset
// bind input data
attribute.composeContent.value = text
}
.store(in: &cell.disposeBag)
attribute.isContentWarningComposing
.receive(on: DispatchQueue.main)
.sink { isContentWarningComposing in
.sink { [weak cell, weak collectionView] isContentWarningComposing in
guard let cell = cell else { return }
guard let collectionView = collectionView else { return }
// self size input cell
collectionView.collectionViewLayout.invalidateLayout()
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
@ -127,27 +133,28 @@ extension ComposeStatusSection {
cell.contentWarningContent
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { text in
.sink { [weak collectionView] text in
guard let collectionView = collectionView else { return }
// self size input cell
collectionView.collectionViewLayout.invalidateLayout()
// bind input data
attribute.contentWarningContent.value = text
}
.store(in: &cell.disposeBag)
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
return cell
case .attachment(let attachmentService):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell
cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value
cell.delegate = composeStatusAttachmentTableViewCellDelegate
attachmentService.imageData
attachmentService.thumbnailImage
.receive(on: DispatchQueue.main)
.sink { imageData in
.sink { [weak cell] thumbnailImage in
guard let cell = cell else { return }
let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1)
guard let imageData = imageData,
let image = UIImage(data: imageData) else {
guard let image = thumbnailImage else {
let placeholder = UIImage.placeholder(
size: size,
color: Asset.Colors.Background.systemGroupedBackground.color
@ -168,17 +175,32 @@ extension ComposeStatusSection {
attachmentService.error.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { uploadState, error in
.sink { [weak cell, weak attachmentService] uploadState, error in
guard let cell = cell else { return }
guard let attachmentService = attachmentService else { return }
cell.attachmentContainerView.emptyStateView.isHidden = error == nil
cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil
if let _ = error {
if let error = error {
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription
} else {
guard let uploadState = uploadState else { return }
switch uploadState {
case is MastodonAttachmentService.UploadState.Finish,
is MastodonAttachmentService.UploadState.Fail:
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
cell.attachmentContainerView.emptyStateView.label.text = {
if let file = attachmentService.file.value {
switch file {
case .jpeg, .png, .gif:
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
case .other:
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
}
} else {
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
}
}()
default:
break
}
@ -206,7 +228,7 @@ extension ComposeStatusSection {
.assign(to: \.value, on: attribute.option)
.store(in: &cell.disposeBag)
cell.delegate = composeStatusPollOptionCollectionViewCellDelegate
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag)
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag)
return cell
case .pollOptionAppendEntry:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell
@ -217,7 +239,8 @@ extension ComposeStatusSection {
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal)
attribute.expiresOption
.receive(on: DispatchQueue.main)
.sink { expiresOption in
.sink { [weak cell] expiresOption in
guard let cell = cell else { return }
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal)
}
.store(in: &cell.disposeBag)
@ -272,7 +295,7 @@ protocol CustomEmojiReplaceableTextInput: AnyObject {
var isFirstResponder: Bool { get }
}
class CustomEmojiReplacableTextInputReference {
class CustomEmojiReplaceableTextInputReference {
weak var value: CustomEmojiReplaceableTextInput?
init(value: CustomEmojiReplaceableTextInput? = nil) {
@ -297,7 +320,7 @@ extension ComposeStatusSection {
static func configureCustomEmojiPicker(
viewModel: CustomEmojiPickerInputViewModel?,
customEmojiReplacableTextInput: CustomEmojiReplaceableTextInput,
customEmojiReplaceableTextInput: CustomEmojiReplaceableTextInput,
disposeBag: inout Set<AnyCancellable>
) {
guard let viewModel = viewModel else { return }
@ -305,9 +328,9 @@ extension ComposeStatusSection {
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] isCustomEmojiComposing in
guard let viewModel = viewModel else { return }
customEmojiReplacableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
customEmojiReplacableTextInput.reloadInputViews()
viewModel.append(customEmojiReplacableTextInput: customEmojiReplacableTextInput)
customEmojiReplaceableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
customEmojiReplaceableTextInput.reloadInputViews()
viewModel.append(customEmojiReplaceableTextInput: customEmojiReplaceableTextInput)
}
.store(in: &disposeBag)
}

View File

@ -36,7 +36,7 @@ extension NotificationSection {
assertionFailure()
return nil
}
let timeText = notification.createAt.shortTimeAgoSinceNow
let timeText = notification.createAt.slowedTimeAgoSinceNow
let actionText = type.actionText
let actionImageName = type.actionImageName
@ -59,7 +59,7 @@ extension NotificationSection {
)
timestampUpdatePublisher
.sink { _ in
let timeText = notification.createAt.shortTimeAgoSinceNow
let timeText = notification.createAt.slowedTimeAgoSinceNow
cell.actionLabel.text = actionText + " · " + timeText
}
.store(in: &cell.disposeBag)
@ -87,7 +87,7 @@ extension NotificationSection {
cell.delegate = delegate
timestampUpdatePublisher
.sink { _ in
let timeText = notification.createAt.shortTimeAgoSinceNow
let timeText = notification.createAt.slowedTimeAgoSinceNow
cell.actionLabel.text = actionText + " · " + timeText
}
.store(in: &cell.disposeBag)

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 {
meta.blurhashImagePublisher()
.receive(on: DispatchQueue.main)
.sink { [weak cell] image in
.sink { [weak blurhashImageCache] image in
guard let blurhashImageCache = blurhashImageCache else { return }
blurhashOverlayImageView.image = image
image?.pngData().flatMap {
blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey)
@ -467,12 +468,12 @@ extension StatusSection {
// set date
let createdAt = (status.reblog ?? status).createdAt
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
timestampUpdatePublisher
.sink { [weak cell] _ in
guard let cell = cell else { return }
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
cell.statusView.dateLabel.accessibilityLabel = createdAt.slowedTimeAgoSinceNow
}
.store(in: &cell.disposeBag)

View File

@ -16,7 +16,8 @@ extension ActiveLabel {
case `default`
case statusHeader
case statusName
case profileField
case profileFieldName
case profileFieldValue
}
convenience init(style: Style) {
@ -46,8 +47,12 @@ extension ActiveLabel {
font = .systemFont(ofSize: 17, weight: .semibold)
textColor = Asset.Colors.Label.primary.color
numberOfLines = 1
case .profileField:
font = .preferredFont(forTextStyle: .body)
case .profileFieldName:
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20)
textColor = Asset.Colors.Label.primary.color
numberOfLines = 1
case .profileFieldValue:
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20)
textColor = Asset.Colors.Label.primary.color
numberOfLines = 1
}
@ -78,10 +83,10 @@ extension ActiveLabel {
extension ActiveLabel {
/// account field
func configure(field: String) {
func configure(field: String, emojiDict: MastodonStatusContent.EmojiDict) {
activeEntities.removeAll()
let parseResult = MastodonField.parse(field: field)
text = parseResult.value
let parseResult = MastodonField.parse(field: field, emojiDict: emojiDict)
text = parseResult.trimmed
activeEntities = parseResult.activeEntities
accessibilityLabel = parseResult.value
}

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,
note: entity.note,
url: entity.url,
emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) },
emojisData: entity.emojis.flatMap { MastodonUser.encode(emojis: $0) },
fieldsData: entity.fields.flatMap { MastodonUser.encode(fields: $0) },
statusesCount: entity.statusesCount,
followingCount: entity.followingCount,
followersCount: entity.followersCount,
@ -101,3 +102,4 @@ extension MastodonUser {
}
extension MastodonUser: EmojiContinaer { }
extension MastodonUser: FieldContinaer { }

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))
}
}
internal enum CleanCache {
/// Successfully clean %@ cache.
internal static func message(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Alerts.CleanCache.Message", String(describing: p1))
}
/// Clean Cache
internal static let title = L10n.tr("Localizable", "Common.Alerts.CleanCache.Title")
}
internal enum Common {
/// Please try again.
internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain")
@ -44,6 +52,12 @@ internal enum L10n {
internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message")
/// Publish Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title")
internal enum AttchmentsMessage {
/// Cannot attach more than one video.
internal static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo")
/// Cannot attach a video to a status that already contains images.
internal static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto")
}
}
internal enum SavePhotoFailure {
/// Please enable photo libaray access permission to save photo.
@ -366,6 +380,10 @@ internal enum L10n {
/// Show more replies
internal static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies")
}
internal enum Timestamp {
/// Now
internal static let now = L10n.tr("Localizable", "Common.Controls.Timeline.Timestamp.Now")
}
}
}
internal enum Countable {
@ -626,6 +644,16 @@ internal enum L10n {
}
}
}
internal enum Fields {
/// Add Row
internal static let addRow = L10n.tr("Localizable", "Scene.Profile.Fields.AddRow")
internal enum Placeholder {
/// Content
internal static let content = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Content")
/// Label
internal static let label = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Label")
}
}
internal enum RelationshipActionAlert {
internal enum ConfirmUnblockUsre {
/// Confirm unblock %@

View File

@ -10,12 +10,25 @@ import ActiveLabel
enum MastodonField {
static func parse(field string: String) -> ParseResult {
static func parse(field string: String, emojiDict: MastodonStatusContent.EmojiDict) -> ParseResult {
// use content parser get emoji entities
let value = string
var string = string
var entities: [ActiveEntity] = []
do {
let contentParseresult = try MastodonStatusContent.parse(content: string, emojiDict: emojiDict)
string = contentParseresult.trimmed
entities.append(contentsOf: contentParseresult.activeEntities)
} catch {
// assertionFailure(error.localizedDescription)
}
let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)")
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
var entities: [ActiveEntity] = []
for match in mentionMatches {
guard let text = string.substring(with: match, at: 0) else { continue }
@ -35,7 +48,7 @@ enum MastodonField {
entities.append(entity)
}
return ParseResult(value: string, activeEntities: entities)
return ParseResult(value: value, trimmed: string, activeEntities: entities)
}
}
@ -43,6 +56,7 @@ enum MastodonField {
extension MastodonField {
struct ParseResult {
let value: String
let trimmed: String
let activeEntities: [ActiveEntity]
}
}

View File

@ -23,7 +23,7 @@ enum MastodonStatusContent {
let pattern = ":\(shortcode):"
content = content.replacingOccurrences(of: pattern, with: emojiNode)
}
return content
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}()
let rootNode = try Node.parse(document: document)
let text = String(rootNode.text)

View File

@ -6,7 +6,7 @@
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.joinmastodon.mastodon-temp</string>
<string>group.org.joinmastodon.app</string>
</array>
</dict>
</plist>

View File

@ -129,7 +129,17 @@ extension StatusProviderFacade {
coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text)
case .url(_, _, let url, _):
guard let url = URL(string: url) else { return }
provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain,
url.pathComponents.count >= 4,
url.pathComponents[0] == "/",
url.pathComponents[1] == "web",
url.pathComponents[2] == "statuses" {
let statusID = url.pathComponents[3]
let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID)
provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
} else {
provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
}
default:
break
}

View File

@ -1,11 +1,15 @@
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
"Common.Alerts.CleanCache.Title" = "Clean Cache";
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
"Common.Alerts.DeletePost.Delete" = "Delete";
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
Please check your internet connection.";
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
@ -128,6 +132,7 @@ Your account looks like this to them.";
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
"Common.Controls.Timeline.Timestamp.Now" = "Now";
"Common.Countable.Photo.Multiple" = "photos";
"Common.Countable.Photo.Single" = "photo";
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
@ -212,6 +217,9 @@ tap the link to confirm your account.";
"Scene.Profile.Dashboard.Followers" = "followers";
"Scene.Profile.Dashboard.Following" = "following";
"Scene.Profile.Dashboard.Posts" = "posts";
"Scene.Profile.Fields.AddRow" = "Add Row";
"Scene.Profile.Fields.Placeholder.Content" = "Content";
"Scene.Profile.Fields.Placeholder.Label" = "Label";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";

View File

@ -1,11 +1,15 @@
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
"Common.Alerts.CleanCache.Title" = "Clean Cache";
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
"Common.Alerts.DeletePost.Delete" = "Delete";
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
Please check your internet connection.";
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
@ -128,6 +132,7 @@ Your account looks like this to them.";
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
"Common.Controls.Timeline.Timestamp.Now" = "Now";
"Common.Countable.Photo.Multiple" = "photos";
"Common.Countable.Photo.Single" = "photo";
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
@ -212,6 +217,9 @@ tap the link to confirm your account.";
"Scene.Profile.Dashboard.Followers" = "followers";
"Scene.Profile.Dashboard.Following" = "following";
"Scene.Profile.Dashboard.Posts" = "posts";
"Scene.Profile.Fields.AddRow" = "Add Row";
"Scene.Profile.Fields.Placeholder.Content" = "Content";
"Scene.Profile.Fields.Placeholder.Label" = "Label";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";

View File

@ -57,6 +57,10 @@ final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell {
_init()
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ComposeStatusAttachmentCollectionViewCell {

View File

@ -61,7 +61,8 @@ final class ComposeViewController: UIViewController, NeedsDependency {
var systemKeyboardHeight: CGFloat = .zero {
didSet {
// note: some system AutoLayout warning here
customEmojiPickerInputView.frame.size.height = systemKeyboardHeight != .zero ? systemKeyboardHeight : 300
let height = max(300, systemKeyboardHeight)
customEmojiPickerInputView.frame.size.height = height
}
}
@ -75,12 +76,15 @@ final class ComposeViewController: UIViewController, NeedsDependency {
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
let composeToolbarBackgroundView = UIView()
private(set) lazy var imagePicker: PHPickerViewController = {
static func createPhotoLibraryPickerConfiguration(selectionLimit: Int = 4) -> PHPickerConfiguration {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 4
let imagePicker = PHPickerViewController(configuration: configuration)
configuration.filter = .any(of: [.images, .videos])
configuration.selectionLimit = selectionLimit
return configuration
}
private(set) lazy var photoLibraryPicker: PHPickerViewController = {
let imagePicker = PHPickerViewController(configuration: ComposeViewController.createPhotoLibraryPickerConfiguration())
imagePicker.delegate = self
return imagePicker
}()
@ -92,7 +96,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
}()
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image, .movie])
documentPickerController.delegate = self
return documentPickerController
}()
@ -567,12 +571,9 @@ extension ComposeViewController {
}
private func resetImagePicker() {
var configuration = PHPickerConfiguration()
configuration.filter = .images
let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count)
configuration.selectionLimit = selectionLimit
imagePicker = createImagePicker(configuration: configuration)
let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit)
photoLibraryPicker = createImagePicker(configuration: configuration)
}
private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController {
@ -610,6 +611,16 @@ extension ComposeViewController {
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
do {
try viewModel.checkAttachmentPrecondition()
} catch {
let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
return
}
guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else {
// TODO: handle error
return
@ -720,7 +731,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
guard let emoji = customEmojiViewModel.emoji(shortcode: name) else { continue }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name)
// set emoji token invisiable (without upper bounce space)
// set emoji token invisible (without upper bounce space)
var attributes = [NSAttributedString.Key: Any]()
attributes[.font] = UIFont.systemFont(ofSize: 0.01)
attributedString.addAttributes(attributes, range: match.range)
@ -802,15 +813,15 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
extension ComposeViewController: TextEditorViewChangeObserver {
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
guard var autoCompeletion = ComposeViewController.scanAutoCompleteInfo(textEditorView: textEditorView) else {
guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textEditorView: textEditorView) else {
viewModel.autoCompleteInfo.value = nil
return
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompeletion.toHighlightEndString), String(autoCompeletion.toCursorString))
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString))
// get layout text bounding rect
var glyphRange = NSRange()
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
let textContainer = textEditorView.layoutManager.textContainers[0]
let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
@ -828,13 +839,13 @@ extension ComposeViewController: TextEditorViewChangeObserver {
viewModel.autoCompleteRetryLayoutTimes.value = 0
// get symbol bounding rect
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.symbolRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
let symbolBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// set bounding rect and trigger layout
autoCompeletion.textBoundingRect = textBoundingRect
autoCompeletion.symbolBoundingRect = symbolBoundingRect
viewModel.autoCompleteInfo.value = autoCompeletion
autoCompletion.textBoundingRect = textBoundingRect
autoCompletion.symbolBoundingRect = symbolBoundingRect
viewModel.autoCompleteInfo.value = autoCompletion
}
struct AutoCompleteInfo {
@ -854,12 +865,14 @@ extension ComposeViewController: TextEditorViewChangeObserver {
private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? {
let text = textEditorView.text
let cursorLocation = textEditorView.selectedRange.location
let cursorIndex = text.index(text.startIndex, offsetBy: cursorLocation)
guard cursorLocation > 0, !text.isEmpty else { return nil }
let _highlighStartIndex: String.Index? = {
var index = text.index(text.startIndex, offsetBy: cursorLocation - 1)
guard textEditorView.selectedRange.location > 0, !text.isEmpty,
let selectedRange = Range(textEditorView.selectedRange, in: text) else {
return nil
}
let cursorIndex = selectedRange.upperBound
let _highlightStartIndex: String.Index? = {
var index = text.index(before: cursorIndex)
while index > text.startIndex {
let char = text[index]
if char == "@" || char == "#" || char == ":" {
@ -876,18 +889,18 @@ extension ComposeViewController: TextEditorViewChangeObserver {
}
}()
guard let highlighStartIndex = _highlighStartIndex else { return nil }
let scanRange = NSRange(highlighStartIndex..<text.endIndex, in: text)
guard let highlightStartIndex = _highlightStartIndex else { return nil }
let scanRange = NSRange(highlightStartIndex..<text.endIndex, in: text)
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
let matchRange = match.range(at: 0)
let matchStartIndex = text.index(text.startIndex, offsetBy: matchRange.location)
let matchEndIndex = text.index(matchStartIndex, offsetBy: matchRange.length)
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
let matchStartIndex = matchRange.lowerBound
let matchEndIndex = matchRange.upperBound
guard matchStartIndex == highlighStartIndex, matchEndIndex >= cursorIndex else { return nil }
let symbolRange = highlighStartIndex..<text.index(after: highlighStartIndex)
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
let symbolString = text[symbolRange]
let toCursorRange = highlighStartIndex..<cursorIndex
let toCursorRange = highlightStartIndex..<cursorIndex
let toCursorString = text[toCursorRange]
let toHighlightEndRange = matchStartIndex..<matchEndIndex
let toHighlightEndString = text[toHighlightEndRange]
@ -913,7 +926,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) {
switch type {
case .photoLibrary:
present(imagePicker, animated: true, completion: nil)
present(photoLibraryPicker, animated: true, completion: nil)
case .camera:
present(imagePickerController, animated: true, completion: nil)
case .browse:
@ -1005,7 +1018,7 @@ extension ComposeViewController: UICollectionViewDelegate {
let emoji = attribute.emoji
let textEditorView = self.textEditorView()
// retrive active text input and insert emoji
// retrieve active text input and insert emoji
// the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue
let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ")
@ -1018,6 +1031,9 @@ extension ComposeViewController: UICollectionViewDelegate {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.collectionView.collectionViewLayout.invalidateLayout()
// make click sound
UIDevice.current.playInputClick()
}
}
} else {
@ -1058,7 +1074,7 @@ extension ComposeViewController: PHPickerViewControllerDelegate {
let service = MastodonAttachmentService(
context: context,
pickerResult: result,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
)
return service
}
@ -1077,7 +1093,7 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC
let attachmentService = MastodonAttachmentService(
context: context,
image: image,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
)
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
}
@ -1092,20 +1108,13 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC
extension ComposeViewController: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
do {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
let imageData = try Data(contentsOf: url)
let attachmentService = MastodonAttachmentService(
context: context,
imageData: imageData,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
)
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
} catch {
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
}
let attachmentService = MastodonAttachmentService(
context: context,
documentURL: url,
initialAuthenticationBox: viewModel.activeAuthenticationBox.value
)
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
}
}
@ -1120,8 +1129,12 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega
var attachmentServices = viewModel.attachmentServices.value
guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
let removedItem = attachmentServices[index]
attachmentServices.remove(at: index)
viewModel.attachmentServices.value = attachmentServices
// cancel task
removedItem.disposeBag.removeAll()
}
}
@ -1365,7 +1378,7 @@ extension ComposeViewController {
case .mediaBrowse:
present(documentPickerController, animated: true, completion: nil)
case .mediaPhotoLibrary:
present(imagePicker, animated: true, completion: nil)
present(photoLibraryPicker, animated: true, completion: nil)
case .mediaCamera:
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
return

View File

@ -291,6 +291,8 @@ final class ComposeViewModel {
)
.receive(on: DispatchQueue.main)
.sink { [weak self] attachmentServices, isPollComposing, pollAttributes in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: trigger attachments upload…", ((#file as NSString).lastPathComponent), #line, #function)
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = diffableDataSource.snapshot()
@ -405,6 +407,59 @@ extension ComposeViewModel {
}
}
extension ComposeViewModel {
enum AttachmentPrecondition: Error, LocalizedError {
case videoAttachWithPhoto
case moreThanOneVideo
var errorDescription: String? {
return L10n.Common.Alerts.PublishPostFailure.title
}
var failureReason: String? {
switch self {
case .videoAttachWithPhoto:
return L10n.Common.Alerts.PublishPostFailure.AttchmentsMessage.videoAttachWithPhoto
case .moreThanOneVideo:
return L10n.Common.Alerts.PublishPostFailure.AttchmentsMessage.moreThanOneVideo
}
}
}
// check exclusive limit:
// - up to 1 video
// - up to 4 photos
func checkAttachmentPrecondition() throws {
let attachmentServices = self.attachmentServices.value
guard !attachmentServices.isEmpty else { return }
var photoAttachmentServices: [MastodonAttachmentService] = []
var videoAttachmentServices: [MastodonAttachmentService] = []
attachmentServices.forEach { service in
guard let file = service.file.value else {
assertionFailure()
return
}
switch file {
case .jpeg, .png, .gif:
photoAttachmentServices.append(service)
case .other:
videoAttachmentServices.append(service)
}
}
if !videoAttachmentServices.isEmpty {
guard videoAttachmentServices.count == 1 else {
throw AttachmentPrecondition.moreThanOneVideo
}
guard photoAttachmentServices.isEmpty else {
throw AttachmentPrecondition.videoAttachWithPhoto
}
}
}
}
// MARK: - MastodonAttachmentServiceDelegate
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {

View File

@ -29,6 +29,8 @@ extension AttachmentContainerView {
label.textAlignment = .center
label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
label.numberOfLines = 2
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.3
return label
}()

View File

@ -218,6 +218,12 @@ extension ComposeToolbarView {
}
private func updateToolbarButtonUserInterfaceStyle() {
// reset emoji
let emojiButtonImage = Asset.Human.faceSmilingAdaptive.image
.af.imageScaled(to: CGSize(width: 20, height: 20))
.withRenderingMode(.alwaysTemplate)
emojiButton.setImage(emojiButtonImage, for: .normal)
switch traitCollection.userInterfaceStyle {
case .light:
mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)

View File

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

View File

@ -12,7 +12,7 @@ final class CustomEmojiPickerInputViewModel {
var disposeBag = Set<AnyCancellable>()
private var customEmojiReplacableTextInputReferences: [CustomEmojiReplacableTextInputReference] = []
private var customEmojiReplaceableTextInputReferences: [CustomEmojiReplaceableTextInputReference] = []
// input
weak var customEmojiPickerInputView: CustomEmojiPickerInputView?
@ -25,27 +25,27 @@ final class CustomEmojiPickerInputViewModel {
extension CustomEmojiPickerInputViewModel {
private func removeEmptyReferences() {
customEmojiReplacableTextInputReferences.removeAll(where: { element in
customEmojiReplaceableTextInputReferences.removeAll(where: { element in
element.value == nil
})
}
func append(customEmojiReplacableTextInput textInput: CustomEmojiReplaceableTextInput) {
func append(customEmojiReplaceableTextInput textInput: CustomEmojiReplaceableTextInput) {
removeEmptyReferences()
let isContains = customEmojiReplacableTextInputReferences.contains(where: { element in
let isContains = customEmojiReplaceableTextInputReferences.contains(where: { element in
element.value === textInput
})
guard !isContains else {
return
}
customEmojiReplacableTextInputReferences.append(CustomEmojiReplacableTextInputReference(value: textInput))
customEmojiReplaceableTextInputReferences.append(CustomEmojiReplaceableTextInputReference(value: textInput))
}
func insertText(_ text: String) -> CustomEmojiReplacableTextInputReference? {
func insertText(_ text: String) -> CustomEmojiReplaceableTextInputReference? {
removeEmptyReferences()
for reference in customEmojiReplacableTextInputReferences {
for reference in customEmojiReplaceableTextInputReferences {
guard reference.value?.isFirstResponder == true else { continue }
reference.value?.insertText(text)
return reference

View File

@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadMiddleState {
stateMachine.enter(Fail.self)
return
}
let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
_ = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
status.id
}

View File

@ -11,6 +11,8 @@ import CoreData
import CoreDataStack
#if DEBUG
import FLEX
extension HomeTimelineViewController {
var debugMenu: UIMenu {
let menu = UIMenu(
@ -19,6 +21,10 @@ extension HomeTimelineViewController {
identifier: nil,
options: .displayInline,
children: [
UIAction(title: "Show FLEX", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.showFLEXAction(action)
}),
moveMenu,
dropMenu,
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
@ -115,6 +121,10 @@ extension HomeTimelineViewController {
extension HomeTimelineViewController {
@objc private func showFLEXAction(_ sender: UIAction) {
FLEXManager.shared.showExplorer()
}
@objc private func moveToTopGapAction(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()

View File

@ -9,6 +9,7 @@ import os.log
import UIKit
import Combine
import GameController
import AuthenticationServices
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
@ -19,6 +20,11 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: MastodonPickServerViewModel!
private(set) lazy var authenticationViewModel = AuthenticationViewModel(
context: context,
coordinator: coordinator,
isAuthenticationExist: false
)
private var expandServerDomainSet = Set<String>()
@ -50,6 +56,8 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
}()
var nextStepButtonBottomLayoutConstraint: NSLayoutConstraint!
var mastodonAuthenticationController: MastodonAuthenticationController?
deinit {
tableViewObservation = nil
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -182,23 +190,26 @@ extension MastodonPickServerViewController {
.assign(to: \.isEnabled, on: nextStepButton)
.store(in: &disposeBag)
viewModel.error
.compactMap { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak self] error in
guard let self = self else { return }
let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
self.coordinator.present(
scene: .alertController(alertController: alertController),
from: nil,
transition: .alertController(animated: true, completion: nil)
)
}
.store(in: &disposeBag)
Publishers.Merge(
viewModel.error,
authenticationViewModel.error
)
.compactMap { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak self] error in
guard let self = self else { return }
let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
self.coordinator.present(
scene: .alertController(alertController: alertController),
from: nil,
transition: .alertController(animated: true, completion: nil)
)
}
.store(in: &disposeBag)
viewModel
authenticationViewModel
.authenticated
.flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in
guard let self = self else { return Just(.success(false)).eraseToAnyPublisher() }
@ -217,7 +228,7 @@ extension MastodonPickServerViewController {
}
.store(in: &disposeBag)
viewModel.isAuthenticating
authenticationViewModel.isAuthenticating
.receive(on: DispatchQueue.main)
.sink { [weak self] isAuthenticating in
guard let self = self else { return }
@ -273,11 +284,15 @@ extension MastodonPickServerViewController {
private func doSignIn() {
guard let server = viewModel.selectedServer.value else { return }
viewModel.isAuthenticating.send(true)
authenticationViewModel.isAuthenticating.send(true)
context.apiService.createApplication(domain: server.domain)
.tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in
.tryMap { response -> AuthenticationViewModel.AuthenticateInfo in
let application = response.value
guard let info = MastodonPickServerViewModel.AuthenticateInfo(domain: server.domain, application: application) else {
guard let info = AuthenticationViewModel.AuthenticateInfo(
domain: server.domain,
application: application,
redirectURI: response.value.redirectURI ?? MastodonAuthenticationController.callbackURL
) else {
throw APIService.APIError.explicit(.badResponse)
}
return info
@ -285,7 +300,7 @@ extension MastodonPickServerViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
self.viewModel.isAuthenticating.send(false)
self.authenticationViewModel.isAuthenticating.send(false)
switch completion {
case .failure(let error):
@ -296,15 +311,19 @@ extension MastodonPickServerViewController {
}
} receiveValue: { [weak self] info in
guard let self = self else { return }
let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.authorizeURL)
self.viewModel.authenticate(
info: info,
pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher
let authenticationController = MastodonAuthenticationController(
context: self.context,
authenticateURL: info.authorizeURL
)
self.viewModel.mastodonPinBasedAuthenticationViewController = self.coordinator.present(
scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel),
from: nil,
transition: .modal(animated: true, completion: nil)
self.mastodonAuthenticationController = authenticationController
authenticationController.authenticationSession?.prefersEphemeralWebBrowserSession = true
authenticationController.authenticationSession?.presentationContextProvider = self
authenticationController.authenticationSession?.start()
self.authenticationViewModel.authenticate(
info: info,
pinCodePublisher: authenticationController.pinCodePublisher
)
}
.store(in: &disposeBag)
@ -313,7 +332,7 @@ extension MastodonPickServerViewController {
private func doSignUp() {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let server = viewModel.selectedServer.value else { return }
viewModel.isAuthenticating.send(true)
authenticationViewModel.isAuthenticating.send(true)
context.apiService.instance(domain: server.domain)
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseFirst, Error>? in
@ -328,7 +347,10 @@ extension MastodonPickServerViewController {
.switchToLatest()
.tryMap { response -> MastodonPickServerViewModel.SignUpResponseSecond in
let application = response.application.value
guard let authenticateInfo = AuthenticationViewModel.AuthenticateInfo(domain: server.domain, application: application) else {
guard let authenticateInfo = AuthenticationViewModel.AuthenticateInfo(
domain: server.domain,
application: application
) else {
throw APIService.APIError.explicit(.badResponse)
}
return MastodonPickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo)
@ -340,7 +362,8 @@ extension MastodonPickServerViewController {
return self.context.apiService.applicationAccessToken(
domain: server.domain,
clientID: authenticateInfo.clientID,
clientSecret: authenticateInfo.clientSecret
clientSecret: authenticateInfo.clientSecret,
redirectURI: authenticateInfo.redirectURI
)
.map { MastodonPickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
.eraseToAnyPublisher()
@ -349,7 +372,7 @@ extension MastodonPickServerViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
self.viewModel.isAuthenticating.send(false)
self.authenticationViewModel.isAuthenticating.send(false)
switch completion {
case .failure(let error):
@ -519,3 +542,10 @@ extension MastodonPickServerViewController: PickServerCellDelegate {
// MARK: - OnboardingViewControllerAppearance
extension MastodonPickServerViewController: OnboardingViewControllerAppearance { }
// MARK: - ASWebAuthenticationPresentationContextProviding
extension MastodonPickServerViewController: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return view.window!
}
}

View File

@ -58,15 +58,11 @@ class MastodonPickServerViewModel: NSObject {
let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
let error = PassthroughSubject<Error, Never>()
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
let error = CurrentValueSubject<Error?, Never>(nil)
let isLoadingIndexedServers = CurrentValueSubject<Bool, Never>(false)
let emptyStateViewState = CurrentValueSubject<EmptyStateViewState, Never>(.none)
var mastodonPinBasedAuthenticationViewController: UIViewController?
init(context: AppContext, mode: PickServerMode) {
self.context = context
self.mode = mode
@ -233,156 +229,6 @@ extension MastodonPickServerViewModel {
}
}
}
// MARK: - SignIn methods & structs
extension MastodonPickServerViewModel {
enum AuthenticationError: Error, LocalizedError {
case badCredentials
case registrationClosed
var errorDescription: String? {
switch self {
case .badCredentials: return "Bad Credentials"
case .registrationClosed: return "Registration Closed"
}
}
var failureReason: String? {
switch self {
case .badCredentials: return "Credentials invalid."
case .registrationClosed: return "Server disallow registration."
}
}
var helpAnchor: String? {
switch self {
case .badCredentials: return "Please try again."
case .registrationClosed: return "Please try another domain."
}
}
}
struct AuthenticateInfo {
let domain: String
let clientID: String
let clientSecret: String
let authorizeURL: URL
init?(domain: String, application: Mastodon.Entity.Application) {
self.domain = domain
guard let clientID = application.clientID,
let clientSecret = application.clientSecret else { return nil }
self.clientID = clientID
self.clientSecret = clientSecret
self.authorizeURL = {
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID)
let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query)
return url
}()
}
}
func authenticate(info: AuthenticateInfo, pinCodePublisher: PassthroughSubject<String, Never>) {
pinCodePublisher
.handleEvents(receiveOutput: { [weak self] _ in
guard let self = self else { return }
// self.isAuthenticating.value = true
self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil)
self.mastodonPinBasedAuthenticationViewController = nil
})
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
guard let self = self else { return nil }
return self.context.apiService
.userAccessToken(
domain: info.domain,
clientID: info.clientID,
clientSecret: info.clientSecret,
code: code
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
let token = response.value
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in success. Token: %s", ((#file as NSString).lastPathComponent), #line, #function, token.accessToken)
return Self.verifyAndSaveAuthentication(
context: self.context,
info: info,
userToken: token
)
}
.eraseToAnyPublisher()
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
// self.isAuthenticating.value = false
self.error.send(error)
case .finished:
break
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
let account = response.value
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s sign in success", ((#file as NSString).lastPathComponent), #line, #function, account.username)
self.authenticated.send((domain: info.domain, account: account))
}
.store(in: &self.disposeBag)
}
static func verifyAndSaveAuthentication(
context: AppContext,
info: AuthenticateInfo,
userToken: Mastodon.Entity.Token
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken)
let managedObjectContext = context.backgroundManagedObjectContext
return context.apiService.accountVerifyCredentials(
domain: info.domain,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
let account = response.value
let mastodonUserRequest = MastodonUser.sortedFetchRequest
mastodonUserRequest.predicate = MastodonUser.predicate(domain: info.domain, id: account.id)
mastodonUserRequest.fetchLimit = 1
guard let mastodonUser = try? managedObjectContext.fetch(mastodonUserRequest).first else {
return Fail(error: AuthenticationError.badCredentials).eraseToAnyPublisher()
}
let property = MastodonAuthentication.Property(
domain: info.domain,
userID: mastodonUser.id,
username: mastodonUser.username,
appAccessToken: userToken.accessToken, // TODO: swap app token
userAccessToken: userToken.accessToken,
clientID: info.clientID,
clientSecret: info.clientSecret
)
return managedObjectContext.performChanges {
_ = APIService.CoreData.createOrMergeMastodonAuthentication(
into: managedObjectContext,
for: mastodonUser,
in: info.domain,
property: property,
networkDate: response.networkDate
)
}
.tryMap { result in
switch result {
case .failure(let error): throw error
case .success: return response
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
// MARK: - SignUp methods & structs
extension MastodonPickServerViewModel {

View File

@ -52,6 +52,8 @@ class PickServerSearchCell: UITableViewCell {
textField.clearButtonMode = .whileEditing
textField.autocapitalizationType = .none
textField.autocorrectionType = .no
textField.returnKeyType = .done
textField.keyboardType = .URL
return textField
}()
@ -78,6 +80,7 @@ extension PickServerSearchCell {
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
searchTextField.delegate = self
contentView.addSubview(bgView)
contentView.addSubview(textFieldBgView)
@ -107,3 +110,12 @@ extension PickServerSearchCell {
delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text)
}
}
// MARK: - UITextFieldDelegate
extension PickServerSearchCell: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return false
}
}

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 authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
let error = CurrentValueSubject<Error?, Never>(nil)
var mastodonPinBasedAuthenticationViewController: UIViewController?
init(context: AppContext, coordinator: SceneCoordinator, isAuthenticationExist: Bool) {
self.context = context
self.coordinator = coordinator
@ -118,18 +116,24 @@ extension AuthenticationViewModel {
let clientID: String
let clientSecret: String
let authorizeURL: URL
let redirectURI: String
init?(domain: String, application: Mastodon.Entity.Application) {
init?(
domain: String,
application: Mastodon.Entity.Application,
redirectURI: String = MastodonAuthenticationController.callbackURL
) {
self.domain = domain
guard let clientID = application.clientID,
let clientSecret = application.clientSecret else { return nil }
self.clientID = clientID
self.clientSecret = clientSecret
self.authorizeURL = {
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID)
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID, redirectURI: redirectURI)
let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query)
return url
}()
self.redirectURI = redirectURI
}
}
@ -138,8 +142,6 @@ extension AuthenticationViewModel {
.handleEvents(receiveOutput: { [weak self] _ in
guard let self = self else { return }
self.isAuthenticating.value = true
self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil)
self.mastodonPinBasedAuthenticationViewController = nil
})
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
guard let self = self else { return nil }
@ -148,6 +150,7 @@ extension AuthenticationViewModel {
domain: info.domain,
clientID: info.clientID,
clientSecret: info.clientSecret,
redirectURI: info.redirectURI,
code: code
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in

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 Combine
import PhotosUI
import ActiveLabel
import AlamofireImage
import CropViewController
import TwitterTextEditor
@ -16,6 +17,7 @@ import TwitterTextEditor
protocol ProfileHeaderViewControllerDelegate: AnyObject {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int)
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
}
final class ProfileHeaderViewController: UIViewController {
@ -96,6 +98,15 @@ extension ProfileHeaderViewController {
])
profileHeaderView.preservesSuperviewLayoutMargins = true
profileHeaderView.fieldCollectionView.delegate = self
viewModel.setupProfileFieldCollectionViewDiffableDataSource(
collectionView: profileHeaderView.fieldCollectionView,
profileFieldCollectionViewCellDelegate: self,
profileFieldAddEntryCollectionViewCellDelegate: self
)
let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ProfileHeaderViewController.longPressReorderGestureHandler(_:)))
profileHeaderView.fieldCollectionView.addGestureRecognizer(longPressReorderGesture)
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pageSegmentedControl)
NSLayoutConstraint.activate([
@ -190,6 +201,17 @@ extension ProfileHeaderViewController {
}
.store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.isEditing,
viewModel.displayProfileInfo.fields
)
.receive(on: RunLoop.main)
.sink { [weak self] isEditing, fields in
guard let self = self else { return }
self.profileHeaderView.fieldCollectionView.isHidden = isEditing ? false : fields.isEmpty
}
.store(in: &disposeBag)
profileHeaderView.editAvatarButton.menu = createAvatarContextMenu()
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
}
@ -198,14 +220,6 @@ extension ProfileHeaderViewController {
super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true
// Deprecated:
// not needs this tweak due to force layout update in the parent
// if !isAdjustBannerImageViewForSafeAreaInset {
// isAdjustBannerImageViewForSafeAreaInset = true
// profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top
// profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top
// }
}
override func viewDidLayoutSubviews() {
@ -265,6 +279,48 @@ extension ProfileHeaderViewController {
delegate?.profileHeaderViewController(self, pageSegmentedControlValueChanged: sender, selectedSegmentIndex: sender.selectedSegmentIndex)
}
// seealso: ProfileHeaderViewModel.setupProfileFieldCollectionViewDiffableDataSource()
@objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) {
guard sender.view === profileHeaderView.fieldCollectionView else {
assertionFailure()
return
}
let collectionView = profileHeaderView.fieldCollectionView
switch(sender.state) {
case .began:
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
let cell = collectionView.cellForItem(at: selectedIndexPath) as? ProfileFieldCollectionViewCell else {
break
}
// check if pressing reorder bar no not
let locationInCell = sender.location(in: cell.reorderBarImageView)
guard cell.reorderBarImageView.bounds.contains(locationInCell) else {
return
}
collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
case .changed:
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
let diffableDataSource = viewModel.fieldDiffableDataSource else {
break
}
guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath),
case .field = item else {
collectionView.cancelInteractiveMovement()
return
}
var position = sender.location(in: collectionView)
position.x = collectionView.frame.width * 0.5
collectionView.updateInteractiveMovementTargetPosition(position)
case .ended:
collectionView.endInteractiveMovement()
collectionView.reloadData()
default:
collectionView.cancelInteractiveMovement()
}
}
}
extension ProfileHeaderViewController {
@ -290,7 +346,7 @@ extension ProfileHeaderViewController {
}
}
func updateHeaderScrollProgress(_ progress: CGFloat) {
func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
updateHeaderBottomShadow(progress: progress)
@ -336,12 +392,12 @@ extension ProfileHeaderViewController {
viewModel.isTitleViewContentOffsetSet.value = true
}
// set avatar
// set avatar fade
if progress > 0 {
setProfileBannerFade(alpha: 0)
} else if progress > -0.3 {
// y = -(10/3)x
let alpha = -10.0 / 3.0 * progress
} else if progress > -abs(throttle) {
// y = -(1/0.8T)x
let alpha = -1 / abs(0.8 * throttle) * progress
setProfileBannerFade(alpha: alpha)
} else {
setProfileBannerFade(alpha: 1)
@ -384,9 +440,9 @@ extension ProfileHeaderViewController: PHPickerViewControllerDelegate {
case .finished:
break
}
} receiveValue: { [weak self] imageData in
} receiveValue: { [weak self] file in
guard let self = self else { return }
guard let imageData = imageData else { return }
guard let imageData = file?.data else { return }
guard let image = UIImage(data: imageData) else { return }
self.cropImage(image: image, pickerViewController: picker)
}
@ -435,3 +491,28 @@ extension ProfileHeaderViewController: CropViewControllerDelegate {
}
}
// MARK: - UICollectionViewDelegate
extension ProfileHeaderViewController: UICollectionViewDelegate {
}
// MARK: - ProfileFieldCollectionViewCellDelegate
extension ProfileHeaderViewController: ProfileFieldCollectionViewCellDelegate {
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, editButtonDidPressed button: UIButton) {
guard let diffableDataSource = viewModel.fieldDiffableDataSource else { return }
guard let indexPath = profileHeaderView.fieldCollectionView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.removeFieldItem(item: item)
}
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
delegate?.profileHeaderViewController(self, profileFieldCollectionViewCell: cell, activeLabel: activeLabel, didSelectActiveEntity: entity)
}
}
// MARK: - ProfileFieldAddEntryCollectionViewCellDelegate
extension ProfileHeaderViewController: ProfileFieldAddEntryCollectionViewCellDelegate {
func ProfileFieldAddEntryCollectionViewCellDidPressed(_ cell: ProfileFieldAddEntryCollectionViewCell) {
viewModel.appendFieldItem()
}
}

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.
//
import os.log
import UIKit
import Combine
import Kanna
@ -12,6 +13,8 @@ import MastodonSDK
final class ProfileHeaderViewModel {
static let maxProfileFieldCount = 4
var disposeBag = Set<AnyCancellable>()
// input
@ -20,11 +23,13 @@ final class ProfileHeaderViewModel {
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false)
let emojiDict = CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>([:])
// output
let displayProfileInfo = ProfileInfo()
let editProfileInfo = ProfileInfo()
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false)
var fieldDiffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>!
init(context: AppContext) {
self.context = context
@ -35,11 +40,64 @@ final class ProfileHeaderViewModel {
.sink { [weak self] isEditing in
guard let self = self else { return }
// setup editing value when toggle to editing
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value)
self.editProfileInfo.fields.value = self.displayProfileInfo.fields.value.map { $0.duplicate() } // set to fields
}
.store(in: &disposeBag)
Publishers.CombineLatest4(
isEditing.removeDuplicates(),
displayProfileInfo.fields.removeDuplicates(),
editProfileInfo.fields.removeDuplicates(),
emojiDict.removeDuplicates()
)
.receive(on: RunLoop.main)
.sink { [weak self] isEditing, displayFields, editingFields, emojiDict in
guard let self = self else { return }
guard let diffableDataSource = self.fieldDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>()
snapshot.appendSections([.main])
let oldSnapshot = diffableDataSource.snapshot()
let oldFieldAttributeDict: [UUID: ProfileFieldItem.FieldItemAttribute] = {
var dict: [UUID: ProfileFieldItem.FieldItemAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
switch item {
case .field(let field, let attribute):
dict[field.id] = attribute
default:
continue
}
}
return dict
}()
let fields: [ProfileFieldItem.FieldValue] = isEditing ? editingFields : displayFields
var items = fields.map { field -> ProfileFieldItem in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: process field item ID: %s", ((#file as NSString).lastPathComponent), #line, #function, field.id.uuidString)
let attribute = oldFieldAttributeDict[field.id] ?? ProfileFieldItem.FieldItemAttribute()
attribute.isEditing = isEditing
attribute.emojiDict.value = emojiDict
attribute.isLast = false
return ProfileFieldItem.field(field: field, attribute: attribute)
}
if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount {
items.append(.addEntry(attribute: ProfileFieldItem.AddEntryItemAttribute()))
}
if let last = items.last?.listSeparatorLineConfigurable {
last.isLast = true
}
snapshot.appendItems(items, toSection: .main)
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
.store(in: &disposeBag)
}
}
@ -49,6 +107,7 @@ extension ProfileHeaderViewModel {
let name = CurrentValueSubject<String?, Never>(nil)
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
let note = CurrentValueSubject<String?, Never>(nil)
let fields = CurrentValueSubject<[ProfileFieldItem.FieldValue], Never>([])
enum ImageResource {
case url(URL?)
@ -57,6 +116,23 @@ extension ProfileHeaderViewModel {
}
}
extension ProfileHeaderViewModel {
func appendFieldItem() {
var fields = editProfileInfo.fields.value
guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return }
fields.append(ProfileFieldItem.FieldValue(name: "", value: ""))
editProfileInfo.fields.value = fields
}
func removeFieldItem(item: ProfileFieldItem) {
var fields = editProfileInfo.fields.value
guard case let .field(field, _) = item else { return }
guard let removeIndex = fields.firstIndex(of: field) else { return }
fields.remove(at: removeIndex)
editProfileInfo.fields.value = fields
}
}
extension ProfileHeaderViewModel {
static func normalize(note: String?) -> String? {
@ -75,6 +151,19 @@ extension ProfileHeaderViewModel {
guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true }
guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true }
guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true }
let isFieldsEqual: Bool = {
let editFields = editProfileInfo.fields.value
let displayFields = displayProfileInfo.fields.value
guard editFields.count == displayFields.count else { return false }
for (editField, displayField) in zip(editFields, displayFields) {
guard editField.name.value == displayField.name.value,
editField.value.value == displayField.value.value else {
return false
}
}
return true
}()
guard isFieldsEqual else { return true }
return false
}
@ -95,6 +184,10 @@ extension ProfileHeaderViewModel {
return image
}()
let fieldsAttributes = editProfileInfo.fields.value.map { fieldValue in
Mastodon.Entity.Field(name: fieldValue.name.value, value: fieldValue.value.value)
}
let query = Mastodon.API.Account.UpdateCredentialQuery(
discoverable: nil,
bot: nil,
@ -104,7 +197,7 @@ extension ProfileHeaderViewModel {
header: nil,
locked: nil,
source: nil,
fieldsAttributes: nil // TODO:
fieldsAttributes: fieldsAttributes
)
return context.apiService.accountUpdateCredentials(
domain: domain,

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 Combine
import ActiveLabel
final class ProfileFieldView: UIView {
let titleLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold))
label.textColor = Asset.Colors.Label.primary.color
label.text = "Title"
var disposeBag = Set<AnyCancellable>()
// output
let name = PassthroughSubject<String, Never>()
let value = PassthroughSubject<String, Never>()
// for custom emoji display
let titleActiveLabel: ActiveLabel = {
let label = ActiveLabel(style: .profileFieldName)
label.configure(content: "title", emojiDict: [:])
return label
}()
// for editing
let titleTextField: UITextField = {
let textField = UITextField()
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20)
textField.textColor = Asset.Colors.Label.primary.color
textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.label
return textField
}()
// for custom emoji display
let valueActiveLabel: ActiveLabel = {
let label = ActiveLabel(style: .profileField)
let label = ActiveLabel(style: .profileFieldValue)
label.configure(content: "value", emojiDict: [:])
return label
}()
let topSeparatorLine = UIView.separatorLine
let bottomSeparatorLine = UIView.separatorLine
// for editing
let valueTextField: UITextField = {
let textField = UITextField()
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20)
textField.textColor = Asset.Colors.Label.primary.color
textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.content
textField.textAlignment = .right
return textField
}()
override init(frame: CGRect) {
super.init(frame: frame)
@ -41,42 +64,67 @@ final class ProfileFieldView: UIView {
extension ProfileFieldView {
private func _init() {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(titleLabel)
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.alignment = .center
// note:
// do not use readable layout guide to workaround SDK issue
// otherwise, the `ProfileFieldCollectionViewCell` cannot display edit button and reorder icon
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: topAnchor),
titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
titleLabel.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
containerStackView.topAnchor.constraint(equalTo: topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
titleActiveLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(titleActiveLabel)
NSLayoutConstraint.activate([
titleActiveLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
titleTextField.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
titleTextField.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(titleTextField)
NSLayoutConstraint.activate([
titleTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
titleTextField.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
valueActiveLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(valueActiveLabel)
containerStackView.addArrangedSubview(valueActiveLabel)
NSLayoutConstraint.activate([
valueActiveLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
valueActiveLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
valueActiveLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
valueActiveLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
valueActiveLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
topSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
addSubview(topSeparatorLine)
valueActiveLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
valueTextField.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(valueTextField)
NSLayoutConstraint.activate([
topSeparatorLine.topAnchor.constraint(equalTo: topAnchor),
topSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor),
topSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
topSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
valueTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomSeparatorLine)
NSLayoutConstraint.activate([
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
bottomSeparatorLine.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
])
titleTextField.isHidden = true
valueTextField.isHidden = true
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: titleTextField)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.name.send(self.titleTextField.text ?? "")
}
.store(in: &disposeBag)
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: valueTextField)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.value.send(self.valueTextField.text ?? "")
}
.store(in: &disposeBag)
}
}
@ -88,7 +136,7 @@ struct ProfileFieldView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
let filedView = ProfileFieldView()
filedView.valueActiveLabel.configure(field: "https://mastodon.online")
filedView.valueActiveLabel.configure(field: "https://mastodon.online", emojiDict: [:])
return filedView
}
.previewLayout(.fixed(width: 375, height: 100))

View File

@ -17,8 +17,8 @@ protocol ProfileHeaderViewDelegate: AnyObject {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView)
}
final class ProfileHeaderView: UIView {
@ -80,6 +80,7 @@ final class ProfileHeaderView: UIView {
view.layer.masksToBounds = true
view.layer.cornerCurve = .continuous
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
view.alpha = 0 // set initial state invisible
return view
}()
@ -152,6 +153,38 @@ final class ProfileHeaderView: UIView {
return textEditorView
}()
static func createFieldCollectionViewLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = .readableContent
let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(1))
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
section.boundarySupplementaryItems = [header, footer]
// note: toggle this not take effect
// section.supplementariesFollowContentInsets = false
return UICollectionViewCompositionalLayout(section: section)
}
let fieldCollectionView: UICollectionView = {
let collectionViewLayout = ProfileHeaderView.createFieldCollectionViewLayout()
let collectionView = UICollectionView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), collectionViewLayout: collectionViewLayout)
collectionView.register(ProfileFieldCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self))
collectionView.register(ProfileFieldAddEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self))
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer)
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer)
collectionView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
collectionView.isScrollEnabled = false
return collectionView
}()
var fieldCollectionViewHeightLaoutConstraint: NSLayoutConstraint!
var fieldCollectionViewHeightObservation: NSKeyValueObservation?
override init(frame: CGRect) {
super.init(frame: frame)
_init()
@ -162,6 +195,10 @@ final class ProfileHeaderView: UIView {
_init()
}
deinit {
fieldCollectionViewHeightObservation = nil
}
}
extension ProfileHeaderView {
@ -193,24 +230,24 @@ extension ProfileHeaderView {
])
// avatar
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.addSubview(avatarImageView)
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.addSubview(avatarImageViewBackgroundView)
NSLayoutConstraint.activate([
avatarImageView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20),
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
avatarImageViewBackgroundView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageViewBackgroundView.bottomAnchor, constant: 20),
])
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView)
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageViewBackgroundView.addSubview(avatarImageView)
NSLayoutConstraint.activate([
avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
])
editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.addSubview(editAvatarBackgroundView)
NSLayoutConstraint.activate([
@ -328,8 +365,20 @@ extension ProfileHeaderView {
bioContainerStackView.addArrangedSubview(bioActiveLabelContainer)
bioContainerStackView.addArrangedSubview(bioTextEditorView)
fieldContainerStackView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addSubview(fieldContainerStackView)
fieldCollectionView.translatesAutoresizingMaskIntoConstraints = false
metaContainerStackView.addArrangedSubview(fieldCollectionView)
fieldCollectionViewHeightLaoutConstraint = fieldCollectionView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh)
NSLayoutConstraint.activate([
fieldCollectionViewHeightLaoutConstraint,
])
fieldCollectionViewHeightObservation = fieldCollectionView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in
guard let self = self else { return }
guard self.fieldCollectionView.contentSize.height != .zero else {
self.fieldCollectionViewHeightLaoutConstraint.constant = 44
return
}
self.fieldCollectionViewHeightLaoutConstraint.constant = self.fieldCollectionView.contentSize.height
})
bringSubviewToFront(bannerContainerView)
bringSubviewToFront(nameContainerStackView)

View File

@ -368,6 +368,18 @@ extension ProfileViewController {
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
.store(in: &disposeBag)
viewModel.fileds
.removeDuplicates()
.map { fields -> [ProfileFieldItem.FieldValue] in
fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value) }
}
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.fields)
.store(in: &disposeBag)
viewModel.emojiDict
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: profileHeaderViewController.viewModel.emojiDict)
.store(in: &disposeBag)
viewModel.username
.map { username in username.flatMap { "@" + $0 } ?? " " }
.receive(on: DispatchQueue.main)
@ -640,7 +652,8 @@ extension ProfileViewController: UIScrollViewDelegate {
// elastically banner image
let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress)
let throttle = ProfileHeaderViewController.headerMinHeight / topMaxContentOffsetY
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress, throttle: throttle)
}
}
@ -664,6 +677,19 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate {
)
}
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
switch entity.type {
case .url(_, _, let url, _):
guard let url = URL(string: url) else { return }
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
case .hashtag(let hashtag, _):
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag)
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show)
default:
break
}
}
}
// MARK: - ProfilePagingViewControllerDelegate
@ -852,20 +878,27 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
case .url(_, _, let url, _):
guard let url = URL(string: url) else { return }
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
case .mention(_, let userInfo):
guard let href = userInfo?["href"] as? String,
let url = URL(string: href) else { return }
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
case .hashtag(let hashtag, _):
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag)
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show)
default:
// TODO:
break
}
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView) {
}
}

View File

@ -39,6 +39,8 @@ class ProfileViewModel: NSObject {
let statusesCount: CurrentValueSubject<Int?, Never>
let followingCount: CurrentValueSubject<Int?, Never>
let followersCount: CurrentValueSubject<Int?, Never>
let fileds: CurrentValueSubject<[Mastodon.Entity.Field], Never>
let emojiDict: CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>
let protected: CurrentValueSubject<Bool?, Never>
let suspended: CurrentValueSubject<Bool, Never>
@ -75,6 +77,8 @@ class ProfileViewModel: NSObject {
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
self.protected = CurrentValueSubject(mastodonUser?.locked)
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
self.fileds = CurrentValueSubject(mastodonUser?.fields ?? [])
self.emojiDict = CurrentValueSubject(mastodonUser?.emojiDict ?? [:])
super.init()
relationshipActionOptionSet
@ -231,6 +235,8 @@ extension ProfileViewModel {
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
self.protected.value = mastodonUser?.locked
self.suspended.value = mastodonUser?.suspended ?? false
self.fileds.value = mastodonUser?.fields ?? []
self.emojiDict.value = mastodonUser?.emojiDict ?? [:]
}
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {

View File

@ -59,11 +59,6 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
var items = [Item]()
for (_, status) in indexStatusTuples {
let targetStatus = status.reblog ?? status
let isStatusTextSensitive: Bool = {
guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute()
items.append(Item.status(objectID: status.objectID, attribute: attribute))
if statusIDsWhichHasGap.contains(status.id) {

View File

@ -12,8 +12,7 @@ import ActiveLabel
import CoreData
import CoreDataStack
import MastodonSDK
import AlamofireImage
import Kingfisher
class SettingsViewController: UIViewController, NeedsDependency {
@ -95,7 +94,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
tableView.backgroundColor = .clear
tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self))
tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self))
@ -186,7 +185,14 @@ class SettingsViewController: UIViewController, NeedsDependency {
}
private func setupView() {
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
view.backgroundColor = UIColor(dynamicProvider: { traitCollection in
switch traitCollection.userInterfaceLevel {
case .elevated where traitCollection.userInterfaceStyle == .dark:
return Asset.Colors.Background.systemElevatedBackground.color
default:
return Asset.Colors.Background.secondarySystemBackground.color
}
})
setupNavigation()
view.addSubview(tableView)
@ -319,36 +325,44 @@ extension SettingsViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let dataSource = viewModel.dataSource else { return }
let item = dataSource.itemIdentifier(for: indexPath)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .boringZone:
guard let url = viewModel.privacyURL else { break }
coordinator.present(
scene: .safari(url: url),
from: self,
transition: .safariPresent(animated: true, completion: nil)
)
case .spicyZone(let link):
// clear media cache
if link.title == L10n.Scene.Settings.Section.Spicyzone.clear {
// clean image cache for AlamofireImage
let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function)
ImageDownloader.defaultURLCache().removeAllCachedResponses()
let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes)
// clean Kingfisher Cache
KingfisherManager.shared.cache.clearDiskCache()
}
// logout
if link.title == L10n.Scene.Settings.Section.Spicyzone.signout {
case .apperance:
// do nothing
break
case .notification:
// do nothing
break
case .boringZone(let link), .spicyZone(let link):
switch link {
case .termsOfService, .privacyPolicy:
// same URL
guard let url = viewModel.privacyURL else { break }
coordinator.present(
scene: .safari(url: url),
from: self,
transition: .safariPresent(animated: true, completion: nil)
)
case .clearMediaCache:
context.purgeCache()
.receive(on: RunLoop.main)
.sink { [weak self] byteCount in
guard let self = self else { return }
let byteCountformatted = AppContext.byteCountFormatter.string(fromByteCount: Int64(byteCount))
let alertController = UIAlertController(
title: L10n.Common.Alerts.CleanCache.title,
message: L10n.Common.Alerts.CleanCache.message(byteCountformatted),
preferredStyle: .alert
)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
self.coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
}
.store(in: &disposeBag)
case .signOut:
alertToSignout()
}
default:
break
}
}
}

View File

@ -176,7 +176,7 @@ class SettingsAppearanceTableViewCell: UITableViewCell {
// MARK: Private methods
private func setupUI() {
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(stackView)

View File

@ -39,7 +39,8 @@ class SettingsSectionHeader: UIView {
init(frame: CGRect, customView: UIView? = nil) {
super.init(frame: frame)
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
backgroundColor = .clear
stackView.addArrangedSubview(titleLabel)
if let view = customView {
stackView.addArrangedSubview(view)

View File

@ -395,7 +395,7 @@ struct MosaicImageView_Previews: PreviewProvider {
let images = self.images.prefix(2)
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, mosiac) in mosaics.enumerated() {
let (imageView, blurhashOverlayImageView) = mosiac
let (imageView, _) = mosiac
imageView.image = images[i]
}
return view
@ -407,7 +407,7 @@ struct MosaicImageView_Previews: PreviewProvider {
let images = self.images.prefix(3)
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, mosiac) in mosaics.enumerated() {
let (imageView, blurhashOverlayImageView) = mosiac
let (imageView, _) = mosiac
imageView.image = images[i]
}
return view
@ -419,7 +419,7 @@ struct MosaicImageView_Previews: PreviewProvider {
let images = self.images.prefix(4)
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, mosiac) in mosaics.enumerated() {
let (imageView, blurhashOverlayImageView) = mosiac
let (imageView, _) = mosiac
imageView.image = images[i]
}
return view

View File

@ -46,7 +46,7 @@ struct MosaicMeta {
let blurhash: String?
let altText: String?
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent)
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent)
func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> {
return Future { promise in

View File

@ -14,7 +14,7 @@ import UIKit
final class VideoPlayerViewModel {
var disposeBag = Set<AnyCancellable>()
static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.video-playback-service.appWillPlayVideo")
static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.app.video-playback-service.appWillPlayVideo")
// input
let previewImageURL: URL?
let videoURL: URL

View File

@ -111,7 +111,7 @@ extension ThreadViewModel.LoadThreadState {
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let _ = viewModel, let stateMachine = stateMachine else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
stateMachine.enter(Loading.self)
}

View File

@ -163,11 +163,18 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
var needsMaskWithAnimation = true
let maskLayerToRect: CGRect? = {
guard case .mosaic = transitionItem.source else { return nil }
guard let navigationBar = toVC.navigationController?.navigationBar else { return nil }
let navigationBarFrameInWindow = toVC.view.convert(navigationBar.frame, to: nil)
var rect = transitionMaskView.frame
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil }
let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil)
// crop rect top edge
var rect = transitionMaskView.frame
let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) }
if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY {
rect.origin.y = toViewFrameInWindow.minY
} else {
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
}
if rect.minY < snapshot.frame.minY {
needsMaskWithAnimation = false
}
@ -177,8 +184,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath
let maskLayerToFinalRect: CGRect? = {
guard case .mosaic = transitionItem.source else { return nil }
guard let tabBarController = toVC.tabBarController else { return nil }
let tabBarFrameInWindow = toVC.view.convert(tabBarController.tabBar.frame, to: nil)
guard let tabBarController = toVC.tabBarController, let tabBarSuperView = tabBarController.tabBar.superview else { return nil }
let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil)
var rect = maskLayerToRect ?? transitionMaskView.frame
let offset = rect.maxY - tabBarFrameInWindow.minY
guard offset > 0 else { return rect }
@ -238,7 +245,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
let fromView = transitionContext.view(forKey: .from),
let _ = transitionContext.view(forKey: .from),
let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController,
let index = fromVC.pagingViewConttroller.currentIndex else {
fatalError()
@ -411,11 +418,18 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
var needsMaskWithAnimation = true
let maskLayerToRect: CGRect? = {
guard case .mosaic = transitionItem.source else { return nil }
guard let navigationBar = toVC.navigationController?.navigationBar else { return nil }
let navigationBarFrameInWindow = toVC.view.convert(navigationBar.frame, to: nil)
var rect = transitionMaskView.frame
rect.origin.y = navigationBarFrameInWindow.maxY
guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil }
let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil)
// crop rect top edge
var rect = transitionMaskView.frame
let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) }
if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY {
rect.origin.y = toViewFrameInWindow.minY
} else {
rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline
}
if rect.minY < snapshot.frame.minY {
needsMaskWithAnimation = false
}
@ -430,8 +444,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
let maskLayerToFinalRect: CGRect? = {
guard case .mosaic = transitionItem.source else { return nil }
guard let tabBarController = toVC.tabBarController else { return nil }
let tabBarFrameInWindow = toVC.view.convert(tabBarController.tabBar.frame, to: nil)
guard let tabBarController = toVC.tabBarController, let tabBarSuperView = tabBarController.tabBar.superview else { return nil }
let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil)
var rect = maskLayerToRect ?? transitionMaskView.frame
let offset = rect.maxY - tabBarFrameInWindow.minY
guard offset > 0 else { return rect }

View File

@ -20,7 +20,11 @@ extension APIService {
#endif
func createApplication(domain: String) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Application>, Error> {
let query = Mastodon.API.App.CreateQuery(clientName: APIService.clientName, website: nil)
let query = Mastodon.API.App.CreateQuery(
clientName: APIService.clientName,
redirectURIs: MastodonAuthenticationController.callbackURL,
website: nil
)
return Mastodon.API.App.create(
session: session,
domain: domain,

View File

@ -17,11 +17,13 @@ extension APIService {
domain: String,
clientID: String,
clientSecret: String,
redirectURI: String,
code: String
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
let query = Mastodon.API.OAuth.AccessTokenQuery(
clientID: clientID,
clientSecret: clientSecret,
redirectURI: redirectURI,
code: code,
grantType: "authorization_code"
)
@ -35,11 +37,13 @@ extension APIService {
func applicationAccessToken(
domain: String,
clientID: String,
clientSecret: String
clientSecret: String,
redirectURI: String
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
let query = Mastodon.API.OAuth.AccessTokenQuery(
clientID: clientID,
clientSecret: clientSecret,
redirectURI: redirectURI,
code: nil,
grantType: "client_credentials"
)

View File

@ -98,6 +98,8 @@ extension APIService.CoreData {
user.update(locked: property.locked)
property.bot.flatMap { user.update(bot: $0) }
property.suspended.flatMap { user.update(suspended: $0) }
property.emojisData.flatMap { user.update(emojisData: $0) }
property.fieldsData.flatMap { user.update(fieldsData: $0) }
user.didUpdate(at: networkDate)
}

View File

@ -14,7 +14,7 @@ import os.log
final class AudioPlaybackService: NSObject {
static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.audio-playback-service.appWillPlayAudio")
static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.app.audio-playback-service.appWillPlayAudio")
var disposeBag = Set<AnyCancellable>()

View File

@ -15,7 +15,7 @@ final class EmojiService {
weak var apiService: APIService?
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.EmojiService.working-queue")
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.EmojiService.working-queue")
private(set) var customEmojiViewModelDict: [String: CustomEmojiViewModel] = [:]
init(apiService: APIService) {

View File

@ -104,7 +104,7 @@ extension Trie {
var values: NSSet {
let valueSet = NSMutableSet(set: self.valueSet)
for (key, value) in children {
for (_, value) in children {
valueSet.addObjects(from: Array(value.values))
}

View File

@ -35,7 +35,7 @@ extension MastodonAttachmentService.UploadState {
return true
}
if service?.imageData.value != nil {
if service?.file.value != nil {
return stateClass == Uploading.self
} else {
return stateClass == Fail.self
@ -53,15 +53,8 @@ extension MastodonAttachmentService.UploadState {
guard let service = service, let stateMachine = stateMachine else { return }
guard let authenticationBox = service.authenticationBox else { return }
guard let imageData = service.imageData.value else { return }
guard let file = service.file.value else { return }
let file: Mastodon.Query.MediaAttachment = {
if imageData.kf.imageFormat == .PNG {
return .png(imageData)
} else {
return .jpeg(imageData)
}
}()
let description = service.description.value
let query = Mastodon.API.Media.UploadMeidaQuery(
file: file,
@ -81,6 +74,7 @@ extension MastodonAttachmentService.UploadState {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
service.error.send(error)
stateMachine.enter(Fail.self)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function)
break

View File

@ -5,11 +5,13 @@
// Created by MainasuK Cirno on 2021-3-17.
//
import os.log
import UIKit
import Combine
import PhotosUI
import Kingfisher
import GameplayKit
import MobileCoreServices
import MastodonSDK
protocol MastodonAttachmentServiceDelegate: AnyObject {
@ -22,16 +24,16 @@ final class MastodonAttachmentService {
weak var delegate: MastodonAttachmentServiceDelegate?
let identifier = UUID()
// input
let context: AppContext
var authenticationBox: AuthenticationService.MastodonAuthenticationBox?
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
let description = CurrentValueSubject<String?, Never>(nil)
// output
// TODO: handle video/GIF/Audio data
let imageData = CurrentValueSubject<Data?, Never>(nil)
let thumbnailImage = CurrentValueSubject<UIImage?, Never>(nil)
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
let description = CurrentValueSubject<String?, Never>(nil)
let error = CurrentValueSubject<Error?, Never>(nil)
private(set) lazy var uploadStateMachine: GKStateMachine = {
@ -50,15 +52,24 @@ final class MastodonAttachmentService {
init(
context: AppContext,
pickerResult: PHPickerResult,
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
) {
self.context = context
self.authenticationBox = initalAuthenticationBox
self.authenticationBox = initialAuthenticationBox
// end init
setupServiceObserver()
PHPickerResultLoader.loadImageData(from: pickerResult)
Just(pickerResult)
.flatMap { result -> AnyPublisher<Mastodon.Query.MediaAttachment?, Error> in
if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) {
return PHPickerResultLoader.loadImageData(from: result).eraseToAnyPublisher()
}
if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) {
return PHPickerResultLoader.loadVideoData(from: result).eraseToAnyPublisher()
}
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
}
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
@ -68,9 +79,9 @@ final class MastodonAttachmentService {
case .finished:
break
}
} receiveValue: { [weak self] imageData in
} receiveValue: { [weak self] file in
guard let self = self else { return }
self.imageData.value = imageData
self.file.value = file
self.uploadStateMachine.enter(UploadState.Initial.self)
}
.store(in: &disposeBag)
@ -79,30 +90,49 @@ final class MastodonAttachmentService {
init(
context: AppContext,
image: UIImage,
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
) {
self.context = context
self.authenticationBox = initalAuthenticationBox
self.authenticationBox = initialAuthenticationBox
// end init
setupServiceObserver()
imageData.value = image.jpegData(compressionQuality: 0.75)
file.value = .jpeg(image.jpegData(compressionQuality: 0.75))
uploadStateMachine.enter(UploadState.Initial.self)
}
init(
context: AppContext,
imageData: Data,
initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
documentURL: URL,
initialAuthenticationBox: AuthenticationService.MastodonAuthenticationBox?
) {
self.context = context
self.authenticationBox = initalAuthenticationBox
self.authenticationBox = initialAuthenticationBox
// end init
setupServiceObserver()
self.imageData.value = imageData
Just(documentURL)
.flatMap { documentURL -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> in
return MastodonAttachmentService.loadAttachment(url: documentURL)
}
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
self.error.value = error
self.uploadStateMachine.enter(UploadState.Fail.self)
case .finished:
break
}
} receiveValue: { [weak self] file in
guard let self = self else { return }
self.file.value = file
self.uploadStateMachine.enter(UploadState.Initial.self)
}
.store(in: &disposeBag)
uploadStateMachine.enter(UploadState.Initial.self)
}
@ -113,6 +143,49 @@ final class MastodonAttachmentService {
self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state)
}
.store(in: &disposeBag)
file
.map { file -> UIImage? in
guard let file = file else {
return nil
}
switch file {
case .jpeg(let data), .png(let data):
return data.flatMap { UIImage(data: $0) }
case .gif:
// TODO:
return nil
case .other(let url, _, _):
guard let url = url, FileManager.default.fileExists(atPath: url.path) else { return nil }
let asset = AVURLAsset(url: url)
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
do {
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
let image = UIImage(cgImage: cgImage)
return image
} catch {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: thumbnail generate fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
return nil
}
}
}
.assign(to: \.value, on: thumbnailImage)
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension MastodonAttachmentService {
enum AttachmentError: Error {
case invalidAttachmentType
case attachmentTooLarge
}
}
@ -136,3 +209,64 @@ extension MastodonAttachmentService: Equatable, Hashable {
}
}
extension MastodonAttachmentService {
private static func createWorkingQueue() -> DispatchQueue {
return DispatchQueue(label: "org.joinmastodon.app.MastodonAttachmentService.\(UUID().uuidString)")
}
static func loadAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
guard let uti = UTType(filenameExtension: url.pathExtension) else {
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
}
if uti.conforms(to: .image) {
return loadImageAttachment(url: url)
} else if uti.conforms(to: .movie) {
return loadVideoAttachment(url: url)
} else {
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
}
}
static func loadImageAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
Future<Mastodon.Query.MediaAttachment, Error> { promise in
createWorkingQueue().async {
do {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
let imageData = try Data(contentsOf: url)
promise(.success(.jpeg(imageData)))
} catch {
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
static func loadVideoAttachment(url: URL) -> AnyPublisher<Mastodon.Query.MediaAttachment, Error> {
Future<Mastodon.Query.MediaAttachment, Error> { promise in
createWorkingQueue().async {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
let fileName = UUID().uuidString
let tempDirectoryURL = FileManager.default.temporaryDirectory
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
do {
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.copyItem(at: url, to: fileURL)
let file = Mastodon.Query.MediaAttachment.other(fileURL, fileExtension: fileURL.pathExtension, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
promise(.success(file))
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
}

View File

@ -17,7 +17,7 @@ final class NotificationService {
var disposeBag = Set<AnyCancellable>()
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue")
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.NotificationService.working-queue")
// input
weak var apiService: APIService?

View File

@ -16,7 +16,7 @@ final class StatusPrefetchingService {
typealias TaskID = String
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPrefetchingService.working-queue")
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.StatusPrefetchingService.working-queue")
var disposeBag = Set<AnyCancellable>()
private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:]

View File

@ -16,7 +16,7 @@ final class StatusPublishService {
var disposeBag = Set<AnyCancellable>()
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPublishService.working-queue")
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.StatusPublishService.working-queue")
// input
var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models

View File

@ -14,7 +14,7 @@ import os.log
final class VideoPlaybackService {
var disposeBag = Set<AnyCancellable>()
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.VideoPlaybackService.working-queue")
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.VideoPlaybackService.working-queue")
private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:]
// only for video kind

View File

@ -10,6 +10,8 @@ import UIKit
import Combine
import CoreData
import CoreDataStack
import AlamofireImage
import Kingfisher
class AppContext: ObservableObject {
@ -99,3 +101,107 @@ class AppContext: ObservableObject {
}
}
extension AppContext {
typealias ByteCount = Int
static let byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
return formatter
}()
private static let purgeCacheWorkingQueue = DispatchQueue(label: "org.joinmastodon.app.AppContext.purgeCacheWorkingQueue")
func purgeCache() -> AnyPublisher<ByteCount, Never> {
Publishers.MergeMany([
AppContext.purgeAlamofireImageCache(),
AppContext.purgeKingfisherCache(),
AppContext.purgeTemporaryDirectory(),
])
.reduce(0, +)
.eraseToAnyPublisher()
}
private static func purgeAlamofireImageCache() -> AnyPublisher<ByteCount, Never> {
Future<ByteCount, Never> { promise in
AppContext.purgeCacheWorkingQueue.async {
// clean image cache for AlamofireImage
let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
ImageDownloader.defaultURLCache().removeAllCachedResponses()
let currentDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
let purgedDiskBytes = max(0, diskBytes - currentDiskBytes)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: purge AlamofireImage cache bytes: %ld -> %ld (%ld)", ((#file as NSString).lastPathComponent), #line, #function, diskBytes, currentDiskBytes, purgedDiskBytes)
promise(.success(purgedDiskBytes))
}
}
.eraseToAnyPublisher()
}
private static func purgeKingfisherCache() -> AnyPublisher<ByteCount, Never> {
Future<ByteCount, Never> { promise in
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
switch result {
case .success(let diskBytes):
KingfisherManager.shared.cache.clearCache()
KingfisherManager.shared.cache.calculateDiskStorageSize { currentResult in
switch currentResult {
case .success(let currentDiskBytes):
let purgedDiskBytes = max(0, Int(diskBytes) - Int(currentDiskBytes))
promise(.success(purgedDiskBytes))
case .failure:
promise(.success(0))
}
}
case .failure:
promise(.success(0))
}
}
}
.eraseToAnyPublisher()
}
private static func purgeTemporaryDirectory() -> AnyPublisher<ByteCount, Never> {
Future<ByteCount, Never> { promise in
AppContext.purgeCacheWorkingQueue.async {
let fileManager = FileManager.default
let temporaryDirectoryURL = fileManager.temporaryDirectory
let resourceKeys = Set<URLResourceKey>([.fileSizeKey, .isDirectoryKey])
guard let directoryEnumerator = fileManager.enumerator(
at: temporaryDirectoryURL,
includingPropertiesForKeys: Array(resourceKeys),
options: .skipsHiddenFiles
) else {
promise(.success(0))
return
}
var fileURLs: [URL] = []
var totalFileSizeInBytes = 0
for case let fileURL as URL in directoryEnumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys),
let isDirectory = resourceValues.isDirectory else {
continue
}
guard !isDirectory else {
continue
}
fileURLs.append(fileURL)
totalFileSizeInBytes += resourceValues.fileSize ?? 0
}
for fileURL in fileURLs {
try? fileManager.removeItem(at: fileURL)
}
promise(.success(totalFileSizeInBytes))
}
}
.eraseToAnyPublisher()
}
//
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: purge temporary directory success", ((#file as NSString).lastPathComponent), #line, #function)
// }
}

View File

@ -10,12 +10,13 @@ import Foundation
import Combine
import MobileCoreServices
import PhotosUI
import MastodonSDK
// load image with low memory usage
// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/
enum PHPickerResultLoader {
static func loadImageData(from result: PHPickerResult) -> Future<Data?, Error> {
static func loadImageData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
Future { promise in
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
if let error = error {
@ -64,7 +65,36 @@ enum PHPickerResultLoader {
let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load image %s", ((#file as NSString).lastPathComponent), #line, #function, dataSize)
promise(.success(data as Data))
let file = Mastodon.Query.MediaAttachment.jpeg(data as Data)
promise(.success(file))
}
}
}
static func loadVideoData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
Future { promise in
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
if let error = error {
promise(.failure(error))
return
}
guard let url = url else {
promise(.success(nil))
return
}
let fileName = UUID().uuidString
let tempDirectoryURL = FileManager.default.temporaryDirectory
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
do {
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.copyItem(at: url, to: fileURL)
let file = Mastodon.Query.MediaAttachment.other(fileURL, fileExtension: fileURL.pathExtension, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
promise(.success(file))
} catch {
promise(.failure(error))
}
}
}
}

View File

@ -217,10 +217,14 @@ extension Mastodon.API.Account {
source.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
source.language.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
}
fieldsAttributes.flatMap { fieldsAttributes in
for fieldsAttribute in fieldsAttributes {
data.append(Data.multipart(key: "fields_attributes[name][]", value: fieldsAttribute.name))
data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value))
if let fieldsAttributes = fieldsAttributes {
if fieldsAttributes.isEmpty {
data.append(Data.multipart(key: "fields_attributes[]", value: ""))
} else {
for (i, fieldsAttribute) in fieldsAttributes.enumerated() {
data.append(Data.multipart(key: "fields_attributes[\(i)][name]", value: fieldsAttribute.name))
data.append(Data.multipart(key: "fields_attributes[\(i)][value]", value: fieldsAttribute.value))
}
}
}

View File

@ -103,7 +103,7 @@ extension Mastodon.API.App {
public init(
clientName: String,
redirectURIs: String = "urn:ietf:wg:oauth:2.0:oob",
redirectURIs: String,
scopes: String? = "read write follow push",
website: String?
) {

View File

@ -42,11 +42,17 @@ extension Mastodon.API.Media {
authorization: authorization
)
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
let serialStream = query.serialStream
request.httpBodyStream = serialStream.boundStreams.input
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.handleEvents(receiveCancel: {
// retain and handle cancel task
serialStream.boundStreams.output.close()
})
.eraseToAnyPublisher()
}
@ -73,15 +79,30 @@ extension Mastodon.API.Media {
}
var body: Data? {
var data = Data()
file.flatMap { data.append(Data.multipart(key: "file", value: $0)) }
thumbnail.flatMap { data.append(Data.multipart(key: "thumbnail", value: $0)) }
description.flatMap { data.append(Data.multipart(key: "description", value: $0)) }
focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) }
// using stream data
return nil
}
var serialStream: SerialStream {
var streams: [InputStream] = []
data.append(Data.multipartEnd())
return data
file.flatMap { value in
streams.append(InputStream(data: Data.multipart(key: "file", value: value)))
value.multipartStreamValue.flatMap { streams.append($0) }
}
thumbnail.flatMap { value in
streams.append(InputStream(data: Data.multipart(key: "thumbnail", value: value)))
value.multipartStreamValue.flatMap { streams.append($0) }
}
description.flatMap { value in
streams.append(InputStream(data: Data.multipart(key: "description", value: value)))
}
focus.flatMap { value in
streams.append(InputStream(data: Data.multipart(key: "focus", value: value)))
}
streams.append(InputStream(data: Data.multipartEnd()))
return SerialStream(streams: streams)
}
}
@ -129,8 +150,45 @@ extension Mastodon.API.Media {
}
.eraseToAnyPublisher()
}
public typealias UpdateMediaQuery = UploadMeidaQuery
public struct UpdateMediaQuery: PutQuery {
public let file: Mastodon.Query.MediaAttachment?
public let thumbnail: Mastodon.Query.MediaAttachment?
public let description: String?
public let focus: String?
public init(
file: Mastodon.Query.MediaAttachment?,
thumbnail: Mastodon.Query.MediaAttachment?,
description: String?,
focus: String?
) {
self.file = file
self.thumbnail = thumbnail
self.description = description
self.focus = focus
}
var contentType: String? {
return Self.multipartContentType()
}
var queryItems: [URLQueryItem]? {
return nil
}
var body: Data? {
var data = Data()
// not modify uploaded binary data
description.flatMap { data.append(Data.multipart(key: "description", value: $0)) }
focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) }
data.append(Data.multipartEnd())
return data
}
}
}

View File

@ -139,7 +139,7 @@ extension Mastodon.API.OAuth {
forceLogin: String? = nil,
responseType: String = "code",
clientID: String,
redirectURI: String = "urn:ietf:wg:oauth:2.0:oob",
redirectURI: String,
scope: String? = "read write follow push"
) {
self.forceLogin = forceLogin
@ -166,7 +166,7 @@ extension Mastodon.API.OAuth {
public init(
clientID: String,
clientSecret: String,
redirectURI: String = "urn:ietf:wg:oauth:2.0:oob",
redirectURI: String,
scope: String? = "read write follow push",
code: String?,
grantType: String

View File

@ -27,5 +27,11 @@ extension Mastodon.Entity {
case value
case verifiedAt = "verified_at"
}
public init(name: String, value: String, verifiedAt: Date? = nil) {
self.name = name
self.value = value
self.verifiedAt = verifiedAt
}
}
}

View File

@ -26,7 +26,12 @@ extension Data {
data.append("Content-Type: \(contentType)\r\n".data(using: .utf8)!)
}
data.append("\r\n".data(using: .utf8)!)
data.append(value.multipartValue)
if value.multipartStreamValue == nil {
data.append(value.multipartValue)
} else {
// needs append stream multipart value outside
// seealso: SerialStream
}
return data
}

View File

@ -16,21 +16,22 @@ extension Mastodon.Query {
/// PNG (Portable Network Graphics) image
case png(Data?)
/// Other media file
case other(Data?, fileExtension: String, mimeType: String)
/// e.g video
case other(URL?, fileExtension: String, mimeType: String)
}
}
extension Mastodon.Query.MediaAttachment {
var data: Data? {
public var data: Data? {
switch self {
case .jpeg(let data): return data
case .gif(let data): return data
case .png(let data): return data
case .other(let data, _, _): return data
case .other: return nil
}
}
var fileName: String {
public var fileName: String {
let name = UUID().uuidString
switch self {
case .jpeg: return "\(name).jpg"
@ -40,7 +41,7 @@ extension Mastodon.Query.MediaAttachment {
}
}
var mimeType: String {
public var mimeType: String {
switch self {
case .jpeg: return "image/jpg"
case .gif: return "image/gif"
@ -56,6 +57,14 @@ extension Mastodon.Query.MediaAttachment {
extension Mastodon.Query.MediaAttachment: MultipartFormValue {
var multipartValue: Data { return data ?? Data() }
var multipartStreamValue: InputStream? {
switch self {
case .other(let url, _, _):
return url.flatMap { InputStream(url: $0) }
default:
return nil
}
}
var multipartContentType: String? { return mimeType }
var multipartFilename: String? { return fileName }
}

View File

@ -13,10 +13,15 @@ enum Multipart {
protocol MultipartFormValue {
var multipartValue: Data { get }
var multipartStreamValue: InputStream? { get }
var multipartContentType: String? { get }
var multipartFilename: String? { get }
}
extension MultipartFormValue {
var multipartStreamValue: InputStream? { nil }
}
extension Bool: MultipartFormValue {
var multipartValue: Data {
switch self {

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>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.joinmastodon.mastodon-temp</string>
<string>group.org.joinmastodon.app</string>
</array>
</dict>
</plist>

17
Podfile
View File

@ -13,6 +13,9 @@ target 'Mastodon' do
pod 'SwiftGen', '~> 6.4.0'
pod 'DateToolsSwift', '~> 5.0.0'
pod 'Kanna', '~> 5.2.2'
# DEBUG
pod 'FLEX', '~> 4.4.0', :configurations => ['Debug']
target 'MastodonTests' do
inherit! :search_paths
@ -23,14 +26,16 @@ target 'Mastodon' do
# Pods for testing
end
target 'NotificationService' do
end
end
target 'AppShared' do
end
target 'NotificationService' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
end
target 'AppShared' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
end
plugin 'cocoapods-keys', {

View File

@ -1,5 +1,6 @@
PODS:
- DateToolsSwift (5.0.0)
- FLEX (4.4.1)
- Kanna (5.2.4)
- Keys (1.0.1)
- SwiftGen (6.4.0)
@ -7,6 +8,7 @@ PODS:
DEPENDENCIES:
- DateToolsSwift (~> 5.0.0)
- FLEX (~> 4.4.0)
- Kanna (~> 5.2.2)
- Keys (from `Pods/CocoaPodsKeys`)
- SwiftGen (~> 6.4.0)
@ -15,6 +17,7 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- DateToolsSwift
- FLEX
- Kanna
- SwiftGen
- "UITextField+Shake"
@ -25,11 +28,12 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287
PODFILE CHECKSUM: 0daad1778e56099e7a7e7ebe3d292d20051840fa
COCOAPODS: 1.10.1