diff --git a/AppShared/UserDefaults+Notification.swift b/AppShared/UserDefaults+Notification.swift new file mode 100644 index 00000000..e743e70a --- /dev/null +++ b/AppShared/UserDefaults+Notification.swift @@ -0,0 +1,40 @@ +// +// UserDefaults+Notification.swift +// AppShared +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import UIKit +import CryptoKit + +extension UserDefaults { + // always use hash value (SHA256) from accessToken as key + private static func deriveKey(from accessToken: String, prefix: String) -> String { + let digest = SHA256.hash(data: Data(accessToken.utf8)) + let bytes = [UInt8](digest) + let hex = bytes.toHexString() + let key = prefix + "@" + hex + return key + } + + private static let notificationCountKeyPrefix = "notification_count" + + public func getNotificationCountWithAccessToken(accessToken: String) -> Int { + let prefix = UserDefaults.notificationCountKeyPrefix + let key = UserDefaults.deriveKey(from: accessToken, prefix: prefix) + return integer(forKey: key) + } + + public func setNotificationCountWithAccessToken(accessToken: String, value: Int) { + let prefix = UserDefaults.notificationCountKeyPrefix + let key = UserDefaults.deriveKey(from: accessToken, prefix: prefix) + setValue(value, forKey: key) + } + + public func increaseNotificationCount(accessToken: String) { + let count = getNotificationCountWithAccessToken(accessToken: accessToken) + setNotificationCountWithAccessToken(accessToken: accessToken, value: count + 1) + } + +} diff --git a/AppShared/UserDefaults.swift b/AppShared/UserDefaults.swift index 67a3cf68..753a3284 100644 --- a/AppShared/UserDefaults.swift +++ b/AppShared/UserDefaults.swift @@ -6,39 +6,8 @@ // import UIKit -import CryptoKit extension UserDefaults { public static let shared = UserDefaults(suiteName: AppName.groupID)! } -extension UserDefaults { - // always use hash value (SHA256) from accessToken as key - private static func deriveKey(from accessToken: String, prefix: String) -> String { - let digest = SHA256.hash(data: Data(accessToken.utf8)) - let bytes = [UInt8](digest) - let hex = bytes.toHexString() - let key = prefix + "@" + hex - return key - } - - private static let notificationCountKeyPrefix = "notification_count" - - public func getNotificationCountWithAccessToken(accessToken: String) -> Int { - let prefix = UserDefaults.notificationCountKeyPrefix - let key = UserDefaults.deriveKey(from: accessToken, prefix: prefix) - return integer(forKey: key) - } - - public func setNotificationCountWithAccessToken(accessToken: String, value: Int) { - let prefix = UserDefaults.notificationCountKeyPrefix - let key = UserDefaults.deriveKey(from: accessToken, prefix: prefix) - setValue(value, forKey: key) - } - - public func increaseNotificationCount(accessToken: String) { - let count = getNotificationCountWithAccessToken(accessToken: accessToken) - setNotificationCountWithAccessToken(accessToken: accessToken, value: count + 1) - } - -} diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents index c26a0bdb..6d576ca1 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData 2.xcdatamodel/contents @@ -63,6 +63,13 @@ + + + + + + + @@ -75,6 +82,7 @@ + @@ -280,7 +288,7 @@ - + @@ -293,5 +301,6 @@ + \ No newline at end of file diff --git a/CoreDataStack/Entity/Instance.swift b/CoreDataStack/Entity/Instance.swift new file mode 100644 index 00000000..8976097e --- /dev/null +++ b/CoreDataStack/Entity/Instance.swift @@ -0,0 +1,70 @@ +// +// Instance.swift +// CoreDataStack +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import Foundation +import CoreData + +public final class Instance: NSManagedObject { + @NSManaged public var domain: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + @NSManaged public private(set) var configurationRaw: Data? + + // MARK: one-to-many relationships + @NSManaged public var authentications: Set +} + +extension Instance { + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Instance.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Instance.updatedAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Instance { + let instance: Instance = context.insertObject() + instance.domain = property.domain + return instance + } + + public func update(configurationRaw: Data?) { + self.configurationRaw = configurationRaw + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } +} + +extension Instance { + public struct Property { + public let domain: String + + public init(domain: String) { + self.domain = domain + } + } +} + +extension Instance: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Instance.createdAt, ascending: false)] + } +} + +extension Instance { + public static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Instance.domain), domain) + } +} diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/MastodonAuthentication.swift index 0ee0e343..66b8ad6a 100644 --- a/CoreDataStack/Entity/MastodonAuthentication.swift +++ b/CoreDataStack/Entity/MastodonAuthentication.swift @@ -30,6 +30,9 @@ final public class MastodonAuthentication: NSManagedObject { // one-to-one relationship @NSManaged public private(set) var user: MastodonUser + // many-to-one relationship + @NSManaged public private(set) var instance: Instance? + } extension MastodonAuthentication { @@ -97,6 +100,12 @@ extension MastodonAuthentication { } } + public func update(instance: Instance) { + if self.instance != instance { + self.instance = instance + } + } + public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate } @@ -143,7 +152,7 @@ extension MastodonAuthentication: Managed { extension MastodonAuthentication { - static func predicate(domain: String) -> NSPredicate { + public static func predicate(domain: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.domain), domain) } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 674821cd..a1283ea0 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -356,6 +356,11 @@ DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; }; + DB73BF3B2711885500781945 /* UserDefaults+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF3A2711885500781945 /* UserDefaults+Notification.swift */; }; + DB73BF4127118B6D00781945 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF4027118B6D00781945 /* Instance.swift */; }; + DB73BF43271192BB00781945 /* InstanceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF42271192BB00781945 /* InstanceService.swift */; }; + DB73BF45271195AC00781945 /* APIService+CoreData+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF44271195AC00781945 /* APIService+CoreData+Instance.swift */; }; + DB73BF47271199CA00781945 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73BF46271199CA00781945 /* Instance.swift */; }; DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; @@ -454,7 +459,6 @@ DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC6482267D0B21007FE9FD /* DifferenceKit */; }; DBAC6485267D0F9E007FE9FD /* StatusNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6484267D0F9E007FE9FD /* StatusNode.swift */; }; DBAC6488267D388B007FE9FD /* ASTableNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6487267D388B007FE9FD /* ASTableNode.swift */; }; - DBAC648A267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */; }; DBAC648F267DC84D007FE9FD /* TableNodeDiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */; }; DBAC6497267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */; }; DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */; }; @@ -1144,6 +1148,11 @@ DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; + DB73BF3A2711885500781945 /* UserDefaults+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Notification.swift"; sourceTree = ""; }; + DB73BF4027118B6D00781945 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = ""; }; + DB73BF42271192BB00781945 /* InstanceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceService.swift; sourceTree = ""; }; + DB73BF44271195AC00781945 /* APIService+CoreData+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Instance.swift"; sourceTree = ""; }; + DB73BF46271199CA00781945 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = ""; }; DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; @@ -1270,7 +1279,6 @@ DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBAC6484267D0F9E007FE9FD /* StatusNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusNode.swift; sourceTree = ""; }; DBAC6487267D388B007FE9FD /* ASTableNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASTableNode.swift; sourceTree = ""; }; - DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSDiffableDataSourceSnapshot.swift; sourceTree = ""; }; DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableNodeDiffableDataSource.swift; sourceTree = ""; }; DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderNode.swift; sourceTree = ""; }; DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderNode.swift; sourceTree = ""; }; @@ -1771,6 +1779,7 @@ DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */, DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */, DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */, + DB73BF42271192BB00781945 /* InstanceService.swift */, ); path = Service; sourceTree = ""; @@ -2079,6 +2088,7 @@ DBAFB7342645463500371D5F /* Emojis.swift */, DBA94439265CC0FC00C537E1 /* Fields.swift */, DBA1DB7F268F84F80052DB59 /* NotificationType.swift */, + DB73BF46271199CA00781945 /* Instance.swift */, ); path = CoreDataStack; sourceTree = ""; @@ -2280,6 +2290,7 @@ 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */, 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, + DB73BF44271195AC00781945 /* APIService+CoreData+Instance.swift */, ); path = CoreData; sourceTree = ""; @@ -2454,6 +2465,7 @@ DB6804912637CD8700430867 /* AppName.swift */, DB6804FC2637CFEC00430867 /* AppSecret.swift */, DB6804D02637CE4700430867 /* UserDefaults.swift */, + DB73BF3A2711885500781945 /* UserDefaults+Notification.swift */, ); path = AppShared; sourceTree = ""; @@ -2636,6 +2648,7 @@ 5B90C46D26259B2C0002E742 /* Setting.swift */, 5B90C46C26259B2C0002E742 /* Subscription.swift */, 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */, + DB73BF4027118B6D00781945 /* Instance.swift */, ); path = Entity; sourceTree = ""; @@ -2716,7 +2729,6 @@ DB0E91E926A9675100BD2ACC /* MetaLabel.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, - DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, @@ -3943,7 +3955,6 @@ DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, - DBAC648A267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift in Sources */, DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, @@ -3993,6 +4004,7 @@ DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */, 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */, + DB73BF47271199CA00781945 /* Instance.swift in Sources */, DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, @@ -4015,6 +4027,7 @@ 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, + DB73BF43271192BB00781945 /* InstanceService.swift in Sources */, DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, @@ -4190,6 +4203,7 @@ DB0C946B26A700AB0088FB11 /* MastodonUser+Property.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, + DB73BF45271195AC00781945 /* APIService+CoreData+Instance.swift in Sources */, DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, @@ -4304,6 +4318,7 @@ buildActionMask = 2147483647; files = ( DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */, + DB73BF3B2711885500781945 /* UserDefaults+Notification.swift in Sources */, DB4932B726F30F0700EF46D4 /* Array.swift in Sources */, DB6804922637CD8700430867 /* AppName.swift in Sources */, DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */, @@ -4324,6 +4339,7 @@ 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */, DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */, + DB73BF4127118B6D00781945 /* Instance.swift in Sources */, DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */, DB89BA1B25C1107F008580ED /* Collection.swift in Sources */, DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index b0f597fa..b5ecab28 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ AppShared.xcscheme_^#shared#^_ orderHint - 44 + 35 CoreDataStack.xcscheme_^#shared#^_ orderHint - 42 + 37 Mastodon - ASDK.xcscheme_^#shared#^_ @@ -37,7 +37,7 @@ Mastodon - ca.xcscheme_^#shared#^_ orderHint - 16 + 18 Mastodon - de.xcscheme_^#shared#^_ @@ -67,7 +67,7 @@ Mastodon - jp.xcscheme_^#shared#^_ orderHint - 14 + 15 Mastodon - nl.xcscheme_^#shared#^_ @@ -87,7 +87,7 @@ Mastodon - zh_Hans.xcscheme_^#shared#^_ orderHint - 15 + 16 Mastodon.xcscheme_^#shared#^_ @@ -97,7 +97,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 41 + 36 MastodonIntents.xcscheme_^#shared#^_ @@ -117,7 +117,7 @@ ShareActionExtension.xcscheme_^#shared#^_ orderHint - 43 + 38 SuppressBuildableAutocreation diff --git a/Mastodon/Extension/CoreDataStack/Instance.swift b/Mastodon/Extension/CoreDataStack/Instance.swift new file mode 100644 index 00000000..6cacd9db --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Instance.swift @@ -0,0 +1,25 @@ +// +// Instance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import UIKit +import CoreDataStack +import MastodonSDK + +extension Instance { + var configuration: Mastodon.Entity.Instance.Configuration? { + guard let configurationRaw = configurationRaw else { return nil } + guard let configuration = try? JSONDecoder().decode(Mastodon.Entity.Instance.Configuration.self, from: configurationRaw) else { + return nil + } + + return configuration + } + + static func encode(configuration: Mastodon.Entity.Instance.Configuration) -> Data? { + return try? JSONEncoder().encode(configuration) + } +} diff --git a/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift b/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift deleted file mode 100644 index c2ff341d..00000000 --- a/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// NSDiffableDataSourceSnapshot.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-6-19. -// - -import UIKit - -//extension NSDiffableDataSourceSnapshot { -// func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? { -// guard 0..() @@ -38,6 +37,24 @@ final class ComposeViewModel: NSObject { var isViewAppeared = false // output + let instanceConfiguration: Mastodon.Entity.Instance.Configuration? + var composeContentLimit: Int { + guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 } + return max(1, maxCharacters) + } + var maxMediaAttachments: Int { + guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else { + return 4 + } + // FIXME: update timeline media preview UI + return min(4, max(1, maxMediaAttachments)) + // return max(1, maxMediaAttachments) + } + var maxPollOptions: Int { + guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 } + return max(2, maxOptions) + } + let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell() let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell() let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell() @@ -128,8 +145,12 @@ final class ComposeViewModel: NSObject { } return CurrentValueSubject(visibility) }() - self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) + let _activeAuthentication = context.authenticationService.activeMastodonAuthentication.value + self.activeAuthentication = CurrentValueSubject(_activeAuthentication) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + // set limit + let _instanceConfiguration = _activeAuthentication?.instance?.configuration + self.instanceConfiguration = _instanceConfiguration super.init() // end init @@ -243,8 +264,9 @@ final class ComposeViewModel: NSObject { let isComposeContentEmpty = composeStatusAttribute.composeContent .map { ($0 ?? "").isEmpty } let isComposeContentValid = characterCount - .map { characterCount -> Bool in - return characterCount <= ComposeViewModel.composeContentLimit + .compactMap { [weak self] characterCount -> Bool in + guard let self = self else { return characterCount <= 500 } + return characterCount <= self.composeContentLimit } let isMediaEmpty = attachmentServices .map { $0.isEmpty } @@ -381,7 +403,7 @@ final class ComposeViewModel: NSObject { .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in guard let self = self else { return } - let shouldMediaDisable = isPollComposing || attachmentServices.count >= 4 + let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments let shouldPollDisable = attachmentServices.count > 0 self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable @@ -455,7 +477,7 @@ extension ComposeViewModel { extension ComposeViewModel { func createNewPollOptionIfPossible() { - guard pollOptionAttributes.value.count < 4 else { return } + guard pollOptionAttributes.value.count < maxPollOptions else { return } let attribute = ComposeStatusPollItem.PollOptionAttribute() pollOptionAttributes.value = pollOptionAttributes.value + [attribute] @@ -488,7 +510,7 @@ extension ComposeViewModel { // check exclusive limit: // - up to 1 video - // - up to 4 photos + // - up to N photos func checkAttachmentPrecondition() throws { let attachmentServices = self.attachmentServices.value guard !attachmentServices.isEmpty else { return } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Instance.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Instance.swift new file mode 100644 index 00000000..614d098a --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Instance.swift @@ -0,0 +1,76 @@ +// +// APIService+CoreData+Instance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeInstance( + into managedObjectContext: NSManagedObjectContext, + domain: String, + entity: Mastodon.Entity.Instance, + networkDate: Date, + log: OSLog + ) -> (instance: Instance, isCreated: Bool) { + // fetch old mastodon user + let old: Instance? = { + let request = Instance.sortedFetchRequest + request.predicate = Instance.predicate(domain: domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let old = old { + // merge old + APIService.CoreData.merge( + instance: old, + entity: entity, + domain: domain, + networkDate: networkDate + ) + return (old, false) + } else { + let instance = Instance.insert( + into: managedObjectContext, + property: Instance.Property(domain: domain) + ) + let configurationRaw = entity.configuration.flatMap { Instance.encode(configuration: $0) } + instance.update(configurationRaw: configurationRaw) + + return (instance, true) + } + } + +} + +extension APIService.CoreData { + + static func merge( + instance: Instance, + entity: Mastodon.Entity.Instance, + domain: String, + networkDate: Date + ) { + guard networkDate > instance.updatedAt else { return } + + let configurationRaw = entity.configuration.flatMap { Instance.encode(configuration: $0) } + instance.update(configurationRaw: configurationRaw) + + instance.didUpdate(at: networkDate) + } + +} diff --git a/Mastodon/Service/InstanceService.swift b/Mastodon/Service/InstanceService.swift new file mode 100644 index 00000000..4fb6309f --- /dev/null +++ b/Mastodon/Service/InstanceService.swift @@ -0,0 +1,103 @@ +// +// InstanceService.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-10-9. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class InstanceService { + + var disposeBag = Set() + + let logger = Logger(subsystem: "InstanceService", category: "Logic") + + // input + let backgroundManagedObjectContext: NSManagedObjectContext + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + + // output + + init( + apiService: APIService, + authenticationService: AuthenticationService + ) { + self.backgroundManagedObjectContext = apiService.backgroundManagedObjectContext + self.apiService = apiService + self.authenticationService = authenticationService + + authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) + .compactMap { $0?.domain } + .removeDuplicates() // prevent infinity loop + .sink { [weak self] domain in + guard let self = self else { return } + self.updateInstance(domain: domain) + } + .store(in: &disposeBag) + } + +} + +extension InstanceService { + func updateInstance(domain: String) { + guard let apiService = self.apiService else { return } + apiService.instance(domain: domain) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + // get instance + let (instance, _) = APIService.CoreData.createOrMergeInstance( + into: managedObjectContext, + domain: domain, + entity: response.value, + networkDate: response.networkDate, + log: OSLog.api + ) + + // update relationship + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain) + request.returnsObjectsAsFaults = false + do { + let authentications = try managedObjectContext.fetch(request) + for authentication in authentications { + authentication.update(instance: instance) + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Instance] update instance failure: \(error.localizedDescription)") + case .finished: + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Instance] update instance for domain: \(domain)") + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + // do nothing + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index d4682ed5..d7c08d47 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -31,6 +31,7 @@ class AppContext: ObservableObject { let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService + let instanceService: InstanceService let blockDomainService: BlockDomainService let statusFilterService: StatusFilterService @@ -87,6 +88,11 @@ class AppContext: ObservableObject { notificationService: _notificationService ) + instanceService = InstanceService( + apiService: _apiService, + authenticationService: _authenticationService + ) + blockDomainService = BlockDomainService( backgroundManagedObjectContext: _backgroundManagedObjectContext, authenticationService: _authenticationService diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift index 226af40f..d0d16ee4 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift @@ -34,6 +34,9 @@ extension Mastodon.Entity { public let thumbnail: String? public let contactAccount: Account? public let rules: [Rule]? + + // https://github.com/mastodon/mastodon/pull/16485 + public let configuration: Configuration? enum CodingKeys: String, CodingKey { case uri @@ -52,6 +55,8 @@ extension Mastodon.Entity { case thumbnail case contactAccount = "contact_account" case rules + + case configuration } } } @@ -86,3 +91,63 @@ extension Mastodon.Entity.Instance { public let text: String } } + +extension Mastodon.Entity.Instance { + public struct Configuration: Codable { + public let statuses: Statuses? + public let mediaAttachments: MediaAttachments? + public let polls: Polls? + + enum CodingKeys: String, CodingKey { + case statuses + case mediaAttachments = "media_attachments" + case polls + } + } +} + +extension Mastodon.Entity.Instance.Configuration { + public struct Statuses: Codable { + public let maxCharacters: Int + public let maxMediaAttachments: Int + public let charactersReservedPerURL: Int + + enum CodingKeys: String, CodingKey { + case maxCharacters = "max_characters" + case maxMediaAttachments = "max_media_attachments" + case charactersReservedPerURL = "characters_reserved_per_url" + } + } + + public struct MediaAttachments: Codable { + public let supportedMIMETypes: [String] + public let imageSizeLimit: Int + public let imageMatrixLimit: Int + public let videoSizeLimit: Int + public let videoFrameRateLimit: Int + public let videoMatrixLimit: Int + + enum CodingKeys: String, CodingKey { + case supportedMIMETypes = "supported_mime_types" + case imageSizeLimit = "image_size_limit" + case imageMatrixLimit = "image_matrix_limit" + case videoSizeLimit = "video_size_limit" + case videoFrameRateLimit = "video_frame_rate_limit" + case videoMatrixLimit = "video_matrix_limit" + } + } + + public struct Polls: Codable { + public let maxOptions: Int + public let maxCharactersPerOption: Int + public let minExpiration: Int + public let maxExpiration: Int + + enum CodingKeys: String, CodingKey { + case maxOptions = "max_options" + case maxCharactersPerOption = "max_characters_per_option" + case minExpiration = "min_expiration" + case maxExpiration = "max_expiration" + } + } +}