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
)