diff --git a/Localization/app.json b/Localization/app.json index ba6aca468..3d99c77da 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -324,9 +324,6 @@ "title": "Find People to Follow", "follow_explain": "When you follow someone, you’ll see their posts in your home feed." }, - "public_timeline": { - "title": "Public" - }, "compose": { "title": { "new_post": "New Post", diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 6faa9959a..c08f88276 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 - 30 + 32 CoreDataStack.xcscheme_^#shared#^_ orderHint - 34 + 31 Mastodon - ASDK.xcscheme_^#shared#^_ @@ -77,7 +77,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 32 + 33 MastodonIntents.xcscheme_^#shared#^_ @@ -97,7 +97,7 @@ ShareActionExtension.xcscheme_^#shared#^_ orderHint - 31 + 30 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift b/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift index 0b4abf233..2e45484c7 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift @@ -70,7 +70,7 @@ extension ComposeStatusPollItem { hasher.combine(id) } - enum ExpiresOption: Equatable, Hashable, CaseIterable { + enum ExpiresOption: String, Equatable, Hashable, CaseIterable { case thirtyMinutes case oneHour case sixHours diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index fd3f5bce0..8f739315d 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -107,6 +107,8 @@ extension ComposeViewModel.PublishState { return subscriptions }() + let idempotencyKey = viewModel.idempotencyKey.value + publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions) .collect() .flatMap { attachments -> AnyPublisher, Error> in @@ -122,6 +124,7 @@ extension ComposeViewModel.PublishState { ) return viewModel.context.apiService.publishStatus( domain: domain, + idempotencyKey: idempotencyKey, query: query, mastodonAuthenticationBox: mastodonAuthenticationBox ) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 601aeae04..f91565d38 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -58,7 +58,10 @@ final class ComposeViewModel: NSObject { }() private(set) lazy var publishStateMachinePublisher = CurrentValueSubject(nil) private(set) var publishDate = Date() // update it when enter Publishing state - + + // TODO: group post material into Hashable class + var idempotencyKey = CurrentValueSubject(UUID().uuidString) + // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) @@ -338,6 +341,9 @@ final class ComposeViewModel: NSObject { if currentState is MastodonAttachmentService.UploadState.Finish { continue } + if currentState is MastodonAttachmentService.UploadState.Processing { + continue + } if currentState is MastodonAttachmentService.UploadState.Uploading { break } @@ -380,6 +386,56 @@ final class ComposeViewModel: NSObject { self.isPollToolbarButtonEnabled.value = !shouldPollDisable }) .store(in: &disposeBag) + + // calculate `Idempotency-Key` + let content = Publishers.CombineLatest3( + composeStatusAttribute.isContentWarningComposing, + composeStatusAttribute.contentWarningContent, + composeStatusAttribute.composeContent + ) + .map { isContentWarningComposing, contentWarningContent, composeContent -> String in + if isContentWarningComposing { + return contentWarningContent + (composeContent ?? "") + } else { + return composeContent ?? "" + } + } + let attachmentIDs = attachmentServices.map { attachments -> String in + let attachmentIDs = attachments.compactMap { $0.attachment.value?.id } + return attachmentIDs.joined(separator: ",") + } + let pollOptionsAndDuration = Publishers.CombineLatest3( + isPollComposing, + pollOptionAttributes, + pollExpiresOptionAttribute.expiresOption + ) + .map { isPollComposing, pollOptionAttributes, expiresOption -> String in + guard isPollComposing else { + return "" + } + + let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",") + return pollOptions + expiresOption.rawValue + } + + Publishers.CombineLatest4( + content, + attachmentIDs, + pollOptionsAndDuration, + selectedStatusVisibility + ) + .map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in + var hasher = Hasher() + hasher.combine(content) + hasher.combine(attachmentIDs) + hasher.combine(pollOptionsAndDuration) + hasher.combine(selectedStatusVisibility.visibility.rawValue) + let hashValue = hasher.finalize() + return "\(hashValue)" + } + .assign(to: \.value, on: idempotencyKey) + .store(in: &disposeBag) + } deinit { diff --git a/Mastodon/Service/APIService/APIService+Media.swift b/Mastodon/Service/APIService/APIService+Media.swift index d6b1d6c21..0c7822c9d 100644 --- a/Mastodon/Service/APIService/APIService+Media.swift +++ b/Mastodon/Service/APIService/APIService+Media.swift @@ -57,6 +57,25 @@ extension APIService { } +extension APIService { + + func getMedia( + attachmentID: Mastodon.Entity.Attachment.ID, + mastodonAuthenticationBox: MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Media.getMedia( + session: session, + domain: mastodonAuthenticationBox.domain, + attachmentID: attachmentID, + authorization: authorization + ) + .eraseToAnyPublisher() + } + +} + extension APIService { func updateMedia( diff --git a/Mastodon/Service/APIService/APIService+Status+Publish.swift b/Mastodon/Service/APIService/APIService+Status+Publish.swift index 45964602f..1bd3363cf 100644 --- a/Mastodon/Service/APIService/APIService+Status+Publish.swift +++ b/Mastodon/Service/APIService/APIService+Status+Publish.swift @@ -16,6 +16,7 @@ extension APIService { func publishStatus( domain: String, + idempotencyKey: String?, query: Mastodon.API.Statuses.PublishStatusQuery, mastodonAuthenticationBox: MastodonAuthenticationBox ) -> AnyPublisher, Error> { @@ -24,6 +25,7 @@ extension APIService { return Mastodon.API.Statuses.publishStatus( session: session, domain: domain, + idempotencyKey: idempotencyKey, query: query, authorization: authorization ) diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift index 8474ac4dd..8ff076dc1 100644 --- a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift @@ -47,7 +47,10 @@ extension MastodonAttachmentService.UploadState { var needsFallback = false override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Fail.self || stateClass == Finish.self || stateClass == Uploading.self + return stateClass == Fail.self + || stateClass == Finish.self + || stateClass == Uploading.self + || stateClass == Processing.self } override func didEnter(from previousState: GKState?) { @@ -96,11 +99,70 @@ extension MastodonAttachmentService.UploadState { } receiveValue: { response in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment %s success: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.url ?? "") service.attachment.value = response.value + if response.statusCode == 202 { + // check if still processing + stateMachine.enter(Processing.self) + } else { + stateMachine.enter(Finish.self) + } + } + .store(in: &service.disposeBag) + } + } + + class Processing: MastodonAttachmentService.UploadState { + + static let retryLimit = 10 + var retryCount = 0 + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self || stateClass == Processing.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let service = service, let stateMachine = stateMachine else { return } + guard let authenticationBox = service.authenticationBox else { return } + guard let attachment = service.attachment.value else { return } + + retryCount += 1 + guard retryCount < Processing.retryLimit else { + stateMachine.enter(Fail.self) + return + } + + service.context.apiService.getMedia( + attachmentID: attachment.id, + mastodonAuthenticationBox: authenticationBox + ) + .retry(3) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let _ = self else { return } + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: get 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: get attachment success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + guard let _ = response.value.url else { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: processing, retry in 2s", ((#file as NSString).lastPathComponent), #line, #function) + self?.stateMachine?.enter(Processing.self) + } + return + } + stateMachine.enter(Finish.self) } .store(in: &service.disposeBag) } - } class Fail: MastodonAttachmentService.UploadState { diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift index 2b08b0db0..e42c9bf2e 100644 --- a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift @@ -41,6 +41,7 @@ final class MastodonAttachmentService { let stateMachine = GKStateMachine(states: [ UploadState.Initial(service: self), UploadState.Uploading(service: self), + UploadState.Processing(service: self), UploadState.Fail(service: self), UploadState.Finish(service: self), ]) diff --git a/MastodonIntent/SendPostIntentHandler.swift b/MastodonIntent/SendPostIntentHandler.swift index 6d5f739fb..75e7049aa 100644 --- a/MastodonIntent/SendPostIntentHandler.swift +++ b/MastodonIntent/SendPostIntentHandler.swift @@ -55,9 +55,12 @@ final class SendPostIntentHandler: NSObject, SendPostIntentHandling { spoilerText: nil, visibility: visibility ) + + let idempotencyKey = UUID().uuidString APIService.shared.publishStatus( domain: box.domain, + idempotencyKey: idempotencyKey, query: query, mastodonAuthenticationBox: box ) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift index d05cac01a..da77c65a1 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift @@ -113,6 +113,49 @@ extension Mastodon.API.Media { } +extension Mastodon.API.Media { + static func getMediaEndpointURL(domain: String, attachmentID: Mastodon.Entity.Attachment.ID) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("media").appendingPathComponent(attachmentID) + } + + /// Get media attachment + /// + /// Get an Attachment, before it is attached to a status and posted, but after it is accepted for processing. + /// + /// - Since: 0.0.0 + /// - Version: 3.4.1 + /// # Last Update + /// 2021/8/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/media/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - mediaID: The ID of attachment + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Attachment` nested in the response + public static func getMedia( + session: URLSession, + domain: String, + attachmentID: Mastodon.Entity.Attachment.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + var request = Mastodon.API.get( + url: getMediaEndpointURL(domain: domain, attachmentID: attachmentID), + query: nil, + authorization: authorization + ) + request.timeoutInterval = 10 // short timeout for quick retry + 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) + } + .eraseToAnyPublisher() + } +} + + extension Mastodon.API.Media { static func updateMediaEndpointURL(domain: String, attachmentID: Mastodon.Entity.Attachment.ID) -> URL { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index e6c8b19d3..0fa1a0d61 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -77,14 +77,18 @@ extension Mastodon.API.Statuses { public static func publishStatus( session: URLSession, domain: String, + idempotencyKey: String?, query: PublishStatusQuery, authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { - let request = Mastodon.API.post( + var request = Mastodon.API.post( url: publishNewStatusEndpointURL(domain: domain), query: query, authorization: authorization ) + if let idempotencyKey = idempotencyKey { + request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key") + } return session.dataTaskPublisher(for: request) .tryMap { data, response in let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index 9c39615f9..db42169d8 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -14,6 +14,7 @@ extension Mastodon.Response { public let value: T // standard fields + public let statusCode: Int? ///< HTTP Code public let date: Date? // application fields @@ -28,6 +29,8 @@ extension Mastodon.Response { public init(value: T, response: URLResponse) { self.value = value + self.statusCode = (response as? HTTPURLResponse)?.statusCode + self.date = { guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "date") else { return nil } return Mastodon.API.httpHeaderDateFormatter.date(from: string) @@ -47,6 +50,7 @@ extension Mastodon.Response { init(value: T, old: Mastodon.Response.Content) { self.value = value + self.statusCode = old.statusCode self.date = old.date self.rateLimit = old.rateLimit self.link = old.link diff --git a/ShareActionExtension/Scene/ShareViewModel.swift b/ShareActionExtension/Scene/ShareViewModel.swift index 62102e660..1aec81bdf 100644 --- a/ShareActionExtension/Scene/ShareViewModel.swift +++ b/ShareActionExtension/Scene/ShareViewModel.swift @@ -346,6 +346,7 @@ extension ShareViewModel { ) return APIService.shared.publishStatus( domain: domain, + idempotencyKey: nil, // FIXME: query: query, mastodonAuthenticationBox: mastodonAuthenticationBox )