From 33401b4e1f2e4eb35f67c6ad8590522db5e550c9 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 30 Apr 2021 12:53:25 +0800 Subject: [PATCH] feature: finish domainBlock action and domainUnblick action --- .../CoreData.xcdatamodel/contents | 3 +- Localization/app.json | 5 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Diffiable/Section/StatusSection.swift | 49 +++++++--- Mastodon/Generated/Strings.swift | 10 +- .../UserProvider/UserProviderFacade.swift | 43 ++++++--- .../Resources/en.lproj/Localizable.strings | 3 +- .../Scene/Profile/ProfileViewController.swift | 6 +- Mastodon/Scene/Profile/ProfileViewModel.swift | 4 + .../APIService/APIService+DomainBlock.swift | 68 +++++++++----- Mastodon/Service/BlockDomainService.swift | 92 +++++++++++++++++-- .../API/Mastodon+API+DomainBlock.swift | 8 +- .../Entity/Mastodon+Entity+Empty.swift | 15 +++ 13 files changed, 234 insertions(+), 74 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 1738e3310..7945a97af 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -26,6 +26,7 @@ + @@ -264,7 +265,7 @@ - + diff --git a/Localization/app.json b/Localization/app.json index 80a9e2dd6..5139cfa64 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -61,7 +61,8 @@ "manually_search": "Manually search instead", "skip": "Skip", "report_user": "Report %s", - "block_domain": "Block %s" + "block_domain": "Block %s", + "unblock_domain": "Unblock %s" }, "status": { "user_reblogged": "%s reblogged", @@ -91,7 +92,7 @@ "pending": "Pending", "block": "Block", "block_user": "Block %s", - "block_domain": "Block %s", + "block_domain": "Domain Blocked", "unblock": "Unblock", "unblock_user": "Unblock %s", "blocked": "Blocked", diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 741947371..4c5c26898 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,7 +51,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", "version": "6.2.1" } }, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 839682c4a..30d3bde4c 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -781,24 +781,43 @@ extension StatusSection { } let author = status.authorForUserProvider let canReport = authenticationBox.userID != author.id - let canBlockDomain = authenticationBox.domain != author.domain + let isInSameDomain = authenticationBox.domain == author.domainFromAcct let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID) cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true - cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( - for: author, - isMuting: isMuting, - isBlocking: isBlocking, - canReport: canReport, - canBlockDomain: canBlockDomain, - provider: userProvider, - cell: cell, - indexPath: indexPath, - sourceView: cell.statusView.actionToolbarContainer.moreButton, - barButtonItem: nil, - shareUser: nil, - shareStatus: status - ) + let managedObjectContext = userProvider.context.backgroundManagedObjectContext + managedObjectContext.perform { + let blockedDomain: DomainBlock? = { + let request = DomainBlock.sortedFetchRequest + request.predicate = DomainBlock.predicate(domain: authenticationBox.domain, userID: authenticationBox.userID, blockedDomain: author.domainFromAcct) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let isDomainBlocking = blockedDomain != nil + DispatchQueue.main.async { + cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( + for: author, + isMuting: isMuting, + isBlocking: isBlocking, + canReport: canReport, + isInSameDomain: isInSameDomain, + isDomainBlocking: isDomainBlocking, + provider: userProvider, + cell: cell, + indexPath: indexPath, + sourceView: cell.statusView.actionToolbarContainer.moreButton, + barButtonItem: nil, + shareUser: nil, + shareStatus: status + ) + } + } } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 18de7e8e5..26816a32f 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -124,14 +124,16 @@ internal enum L10n { internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") /// Try Again internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain") + /// Unblock %@ + internal static func unblockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.UnblockDomain", String(describing: p1)) + } } internal enum Firendship { /// Block internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") - /// Block %@ - internal static func blockDomain(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain", String(describing: p1)) - } + /// Domain Blocked + internal static let blockDomain = L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain") /// Blocked internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") /// Block %@ diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index a4356e71a..d1615212b 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -159,7 +159,8 @@ extension UserProviderFacade { isMuting: Bool, isBlocking: Bool, canReport: Bool, - canBlockDomain: Bool, + isInSameDomain: Bool, + isDomainBlocking: Bool, provider: UserProvider, cell: UITableViewCell?, indexPath: IndexPath?, @@ -248,21 +249,37 @@ extension UserProviderFacade { children.append(reportAction) } - if canBlockDomain { - let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domain), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in - guard let provider = provider else { return } - let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.BlockDomain.message(mastodonUser.domain), preferredStyle: .alert) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in + if !isInSameDomain { + if isDomainBlocking { + let unblockDomainAction = UIAction(title: L10n.Common.Controls.Actions.unblockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + BlockDomainService(userProvider: provider, + cell: cell, + indexPath: indexPath + ) + .unblockDomain() + } + children.append(unblockDomainAction) + } else { + let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.BlockDomain.message(mastodonUser.domainFromAcct), preferredStyle: .alert) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in + } + alertController.addAction(cancelAction) + let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { _ in + BlockDomainService(userProvider: provider, + cell: cell, + indexPath: indexPath + ) + .blockDomain() + } + alertController.addAction(blockDomainAction) + provider.present(alertController, animated: true, completion: nil) } - alertController.addAction(cancelAction) - let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { _ in - BlockDomainService(context: provider.context).blockDomain(domain: mastodonUser.domain) - } - alertController.addAction(blockDomainAction) - provider.present(alertController, animated: true, completion: nil) + children.append(blockDomainAction) } - children.append(blockDomainAction) } if let shareUser = shareUser { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 983de7327..08c23a305 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -41,8 +41,9 @@ Please check your internet connection."; "Common.Controls.Actions.Skip" = "Skip"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; +"Common.Controls.Actions.UnblockDomain" = "Unblock %@"; "Common.Controls.Firendship.Block" = "Block"; -"Common.Controls.Firendship.BlockDomain" = "Block %@"; +"Common.Controls.Firendship.BlockDomain" = "Domain Blocked"; "Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.EditInfo" = "Edit info"; diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 21571faa7..317d51c03 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -377,14 +377,16 @@ extension ProfileViewController { guard let currentDomain = self.viewModel.domain.value else { return } let isMuting = relationshipActionOptionSet.contains(.muting) let isBlocking = relationshipActionOptionSet.contains(.blocking) + let isDomainBlocking = relationshipActionOptionSet.contains(.domainBlocking) let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value - let canBlockDomain = mastodonUser.domain != currentDomain + let isInSameDomain = mastodonUser.domainFromAcct == currentDomain self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu( for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, canReport: true, - canBlockDomain: canBlockDomain, + isInSameDomain: isInSameDomain, + isDomainBlocking: isDomainBlocking, provider: self, cell: nil, indexPath: nil, diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 445952e96..c19ae2f63 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -327,6 +327,7 @@ extension ProfileViewModel { case muting case blocked case blocking + case domainBlocking case suspended case edit case editing @@ -349,6 +350,7 @@ extension ProfileViewModel { static let muting = RelationshipAction.muting.option static let blocked = RelationshipAction.blocked.option static let blocking = RelationshipAction.blocking.option + static let domainBlocking = RelationshipAction.domainBlocking.option static let suspended = RelationshipAction.suspended.option static let edit = RelationshipAction.edit.option static let editing = RelationshipAction.editing.option @@ -379,6 +381,7 @@ extension ProfileViewModel { case .muting: return L10n.Common.Controls.Firendship.muted case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user case .blocking: return L10n.Common.Controls.Firendship.blocked + case .domainBlocking: return L10n.Common.Controls.Firendship.blockDomain case .suspended: return L10n.Common.Controls.Firendship.follow case .edit: return L10n.Common.Controls.Firendship.editInfo case .editing: return L10n.Common.Controls.Actions.done @@ -400,6 +403,7 @@ extension ProfileViewModel { case .muting: return Asset.Colors.Background.alertYellow.color case .blocked: return Asset.Colors.Button.normal.color case .blocking: return Asset.Colors.Background.danger.color + case .domainBlocking: return Asset.Colors.Button.normal.color case .suspended: return Asset.Colors.Button.normal.color case .edit: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color diff --git a/Mastodon/Service/APIService/APIService+DomainBlock.swift b/Mastodon/Service/APIService/APIService+DomainBlock.swift index bb54d2983..7d9936c5a 100644 --- a/Mastodon/Service/APIService/APIService+DomainBlock.swift +++ b/Mastodon/Service/APIService/APIService+DomainBlock.swift @@ -5,16 +5,15 @@ // Created by sxiaojian on 2021/4/29. // -import Foundation import Combine +import CommonOSLog import CoreData import CoreDataStack -import CommonOSLog import DateToolsSwift +import Foundation import MastodonSDK extension APIService { - func getDomainblocks( domain: String, limit: Int = onceRequestDomainBlocksMaxCount, @@ -32,10 +31,10 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { + self.backgroundManagedObjectContext.performChanges { response.value.forEach { domain in // use constrain to avoid repeated save - let _ = DomainBlock.insert( + _ = DomainBlock.insert( into: self.backgroundManagedObjectContext, blockedDomain: domain, domain: authorizationBox.domain, @@ -58,28 +57,33 @@ extension APIService { } func blockDomain( - domain: String, + user: MastodonUser, authorizationBox: AuthenticationService.MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let authorization = authorizationBox.userAuthorization return Mastodon.API.DomainBlock.blockDomain( domain: authorizationBox.domain, - blockDomain: domain, + blockDomain: user.domainFromAcct, session: session, authorization: authorization ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - let _ = DomainBlock.insert( + .flatMap { response -> AnyPublisher, Error> in + self.backgroundManagedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + _ = DomainBlock.insert( into: self.backgroundManagedObjectContext, - blockedDomain: domain, + blockedDomain: user.domainFromAcct, domain: authorizationBox.domain, userID: authorizationBox.userID ) + user.update(isDomainBlocking: true, by: requestMastodonUser) } .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in + .tryMap { result -> Mastodon.Response.Content in switch result { case .success: return response @@ -93,28 +97,42 @@ extension APIService { } func unblockDomain( - domain: String, + user: MastodonUser, authorizationBox: AuthenticationService.MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let authorization = authorizationBox.userAuthorization return Mastodon.API.DomainBlock.unblockDomain( domain: authorizationBox.domain, - blockDomain: domain, + blockDomain: user.domainFromAcct, session: session, authorization: authorization ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { -// let _ = DomainBlock.insert( -// into: self.backgroundManagedObjectContext, -// blockedDomain: domain, -// domain: authorizationBox.domain, -// userID: authorizationBox.userID -// ) + .flatMap { response -> AnyPublisher, Error> in + self.backgroundManagedObjectContext.performChanges { + let blockedDomain: DomainBlock? = { + let request = DomainBlock.sortedFetchRequest + request.predicate = DomainBlock.predicate(domain: authorizationBox.domain, userID: authorizationBox.userID, blockedDomain: user.domainFromAcct) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let blockedDomain = blockedDomain { + self.backgroundManagedObjectContext.delete(blockedDomain) + } + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + user.update(isDomainBlocking: false, by: requestMastodonUser) } .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in + .tryMap { result -> Mastodon.Response.Content in switch result { case .success: return response diff --git a/Mastodon/Service/BlockDomainService.swift b/Mastodon/Service/BlockDomainService.swift index 91d226989..0cc35f0d2 100644 --- a/Mastodon/Service/BlockDomainService.swift +++ b/Mastodon/Service/BlockDomainService.swift @@ -8,15 +8,95 @@ import CoreData import CoreDataStack import Foundation +import Combine +import MastodonSDK +import OSLog +import UIKit final class BlockDomainService { - let context: AppContext - - init(context: AppContext) { - self.context = context + let userProvider: UserProvider + let cell: UITableViewCell? + let indexPath: IndexPath? + init(userProvider: UserProvider, + cell: UITableViewCell?, + indexPath: IndexPath? + ) { + self.userProvider = userProvider + self.cell = cell + self.indexPath = indexPath } - func blockDomain(domain: String) { - + func blockDomain() { + guard let activeMastodonAuthenticationBox = self.userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let context = self.userProvider.context else { + return + } + var mastodonUser: AnyPublisher + if let cell = self.cell, let indexPath = self.indexPath { + mastodonUser = userProvider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + } else { + mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() + } + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + return context.apiService.blockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) + } + .switchToLatest() + .flatMap { response -> AnyPublisher, Error> in + return context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) + } + .sink { completion in + switch completion { + case .finished: + break + case .failure(let error): + print(error) + } + } receiveValue: { response in + print(response) + } + .store(in: &userProvider.disposeBag) + } + + func unblockDomain() { + guard let activeMastodonAuthenticationBox = self.userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let context = self.userProvider.context else { + return + } + var mastodonUser: AnyPublisher + if let cell = self.cell, let indexPath = self.indexPath { + mastodonUser = userProvider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + } else { + mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() + } + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + return context.apiService.unblockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) + } + .switchToLatest() + .flatMap { response -> AnyPublisher, Error> in + return context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) + } + .sink { completion in + switch completion { + case .finished: + break + case .failure(let error): + print(error) + } + } receiveValue: { response in + print(response) + } + .store(in: &userProvider.disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift index f0ee51d90..356ac68a0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift @@ -54,7 +54,7 @@ extension Mastodon.API.DomainBlock { blockDomain:String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let query = Mastodon.API.DomainBlock.BlockQuery(domain: blockDomain) let request = Mastodon.API.post( url: domainBlockEndpointURL(domain: domain), @@ -63,7 +63,7 @@ extension Mastodon.API.DomainBlock { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: String.self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Empty.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() @@ -84,7 +84,7 @@ extension Mastodon.API.DomainBlock { blockDomain:String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let query = Mastodon.API.DomainBlock.BlockQuery(domain: blockDomain) let request = Mastodon.API.delete( url: domainBlockEndpointURL(domain: domain), @@ -93,7 +93,7 @@ extension Mastodon.API.DomainBlock { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: String.self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Empty.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift new file mode 100644 index 000000000..93ee8ed37 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift @@ -0,0 +1,15 @@ +// +// File.swift +// +// +// Created by sxiaojian on 2021/4/30. +// + +import Foundation + +extension Mastodon.Entity { + public struct Empty: Codable { + + } + +}