commit
aae3669a76
|
@ -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",
|
||||
|
|
|
@ -7,12 +7,12 @@
|
|||
<key>AppShared.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>30</integer>
|
||||
<integer>32</integer>
|
||||
</dict>
|
||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>34</integer>
|
||||
<integer>31</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -77,7 +77,7 @@
|
|||
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>32</integer>
|
||||
<integer>33</integer>
|
||||
</dict>
|
||||
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -97,7 +97,7 @@
|
|||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>31</integer>
|
||||
<integer>30</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -107,6 +107,8 @@ extension ComposeViewModel.PublishState {
|
|||
return subscriptions
|
||||
}()
|
||||
|
||||
let idempotencyKey = viewModel.idempotencyKey.value
|
||||
|
||||
publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions)
|
||||
.collect()
|
||||
.flatMap { attachments -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in
|
||||
|
@ -122,6 +124,7 @@ extension ComposeViewModel.PublishState {
|
|||
)
|
||||
return viewModel.context.apiService.publishStatus(
|
||||
domain: domain,
|
||||
idempotencyKey: idempotencyKey,
|
||||
query: query,
|
||||
mastodonAuthenticationBox: mastodonAuthenticationBox
|
||||
)
|
||||
|
|
|
@ -59,6 +59,9 @@ final class ComposeViewModel: NSObject {
|
|||
private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
|
||||
private(set) var publishDate = Date() // update it when enter Publishing state
|
||||
|
||||
// TODO: group post material into Hashable class
|
||||
var idempotencyKey = CurrentValueSubject<String, Never>(UUID().uuidString)
|
||||
|
||||
// UI & UX
|
||||
let title: CurrentValueSubject<String, Never>
|
||||
let shouldDismiss = CurrentValueSubject<Bool, Never>(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 {
|
||||
|
|
|
@ -57,6 +57,25 @@ extension APIService {
|
|||
|
||||
}
|
||||
|
||||
extension APIService {
|
||||
|
||||
func getMedia(
|
||||
attachmentID: Mastodon.Entity.Attachment.ID,
|
||||
mastodonAuthenticationBox: MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
|
||||
return Mastodon.API.Media.getMedia(
|
||||
session: session,
|
||||
domain: mastodonAuthenticationBox.domain,
|
||||
attachmentID: attachmentID,
|
||||
authorization: authorization
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension APIService {
|
||||
|
||||
func updateMedia(
|
||||
|
|
|
@ -16,6 +16,7 @@ extension APIService {
|
|||
|
||||
func publishStatus(
|
||||
domain: String,
|
||||
idempotencyKey: String?,
|
||||
query: Mastodon.API.Statuses.PublishStatusQuery,
|
||||
mastodonAuthenticationBox: MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
|
||||
|
@ -24,6 +25,7 @@ extension APIService {
|
|||
return Mastodon.API.Statuses.publishStatus(
|
||||
session: session,
|
||||
domain: domain,
|
||||
idempotencyKey: idempotencyKey,
|
||||
query: query,
|
||||
authorization: authorization
|
||||
)
|
||||
|
|
|
@ -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 ?? "<nil>")
|
||||
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 {
|
||||
|
|
|
@ -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),
|
||||
])
|
||||
|
|
|
@ -56,8 +56,11 @@ final class SendPostIntentHandler: NSObject, SendPostIntentHandling {
|
|||
visibility: visibility
|
||||
)
|
||||
|
||||
let idempotencyKey = UUID().uuidString
|
||||
|
||||
APIService.shared.publishStatus(
|
||||
domain: box.domain,
|
||||
idempotencyKey: idempotencyKey,
|
||||
query: query,
|
||||
mastodonAuthenticationBox: box
|
||||
)
|
||||
|
|
|
@ -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<Mastodon.Response.Content<Mastodon.Entity.Attachment>, 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 {
|
||||
|
|
|
@ -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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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)
|
||||
|
|
|
@ -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<O>(value: T, old: Mastodon.Response.Content<O>) {
|
||||
self.value = value
|
||||
self.statusCode = old.statusCode
|
||||
self.date = old.date
|
||||
self.rateLimit = old.rateLimit
|
||||
self.link = old.link
|
||||
|
|
|
@ -346,6 +346,7 @@ extension ShareViewModel {
|
|||
)
|
||||
return APIService.shared.publishStatus(
|
||||
domain: domain,
|
||||
idempotencyKey: nil, // FIXME:
|
||||
query: query,
|
||||
mastodonAuthenticationBox: mastodonAuthenticationBox
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue