From e87dcfe48e9044a28005991789972f46ddef0115 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 11 May 2023 09:59:57 -0400 Subject: [PATCH] Add support for editing posts Closes #321 --- .../Sources/ComposeUI/API/PostService.swift | 92 ++++++++++++++---- .../ComposeUI/ComposeMastodonContext.swift | 2 + .../Controllers/AttachmentRowController.swift | 2 +- .../AttachmentThumbnailController.swift | 22 ++++- .../Controllers/ComposeController.swift | 40 +++++--- .../Controllers/DraftsController.swift | 10 +- .../Controllers/ToolbarController.swift | 2 + .../Sources/ComposeUI/CoreData/Draft.swift | 1 + .../ComposeUI/CoreData/DraftAttachment.swift | 31 +++++- .../Drafts.xcdatamodel/contents | 4 + .../CoreData/DraftsPersistentContainer.swift | 61 ++++++++++++ .../InstanceFeatures/InstanceFeatures.swift | 9 ++ .../Pachyderm/Sources/Pachyderm/Client.swift | 36 ++++++- .../Model/EditStatusParameters.swift | 97 +++++++++++++++++++ .../Sources/Pachyderm/Model/Status.swift | 4 + .../Pachyderm/Model/StatusSource.swift | 20 ++++ ShareExtension/ShareHostingController.swift | 1 + ShareExtension/ShareMastodonContext.swift | 3 + Tusker.xcodeproj/project.pbxproj | 4 + Tusker/API/FetchStatusSourceService.swift | 26 +++++ Tusker/API/MastodonController.swift | 14 +++ .../Tusker.xcdatamodel/contents | 4 +- .../Compose/ComposeHostingController.swift | 5 + Tusker/Screens/Utilities/Previewing.swift | 35 +++++++ .../AttachmentsContainerView.swift | 23 ++++- .../Status/StatusCollectionViewCell.swift | 7 +- 26 files changed, 503 insertions(+), 52 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/EditStatusParameters.swift create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/StatusSource.swift create mode 100644 Tusker/API/FetchStatusSourceService.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index 015f1935..1863238c 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -15,16 +15,14 @@ class PostService: ObservableObject { private let mastodonController: ComposeMastodonContext private let config: ComposeUIConfig private let draft: Draft - let totalSteps: Int @Published var currentStep = 1 + @Published private(set) var totalSteps = 2 init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) { self.mastodonController = mastodonController self.config = config self.draft = draft - // 2 steps (request data, then upload) for each attachment - self.totalSteps = 2 + (draft.attachments.count * 2) } func post() async throws { @@ -40,32 +38,73 @@ class PostService: ObservableObject { let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil let sensitive = contentWarning != nil - let request = Client.createStatus( - text: textForPosting(), - contentType: config.contentType, - inReplyTo: draft.inReplyToID, - media: uploadedAttachments, - sensitive: sensitive, - spoilerText: contentWarning, - visibility: draft.visibility, - language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil, - pollOptions: draft.poll?.pollOptions.map(\.text), - pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), - pollMultiple: draft.poll?.multiple, - localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil - ) + let request: Request + + if let editedStatusID = draft.editedStatusID { + if mastodonController.instanceFeatures.needsEditAttachmentsInSeparateRequest { + await updateEditedAttachments() + } + + request = Client.editStatus( + id: editedStatusID, + text: textForPosting(), + contentType: config.contentType, + spoilerText: contentWarning, + sensitive: sensitive, + language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil, + mediaIDs: uploadedAttachments, + mediaAttributes: draft.draftAttachments.compactMap { + if let id = $0.editedAttachmentID { + return EditStatusMediaAttributes(id: id, description: $0.attachmentDescription, focus: nil) + } else { + return nil + } + }, + poll: draft.poll.map { + EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple) + } + ) + } else { + request = Client.createStatus( + text: textForPosting(), + contentType: config.contentType, + inReplyTo: draft.inReplyToID, + mediaIDs: uploadedAttachments, + sensitive: sensitive, + spoilerText: contentWarning, + visibility: draft.visibility, + language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil, + pollOptions: draft.poll?.pollOptions.map(\.text), + pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), + pollMultiple: draft.poll?.multiple, + localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil + ) + } + do { - let (_, _) = try await mastodonController.run(request) + let (status, _) = try await mastodonController.run(request) currentStep += 1 + mastodonController.storeCreatedStatus(status) } catch let error as Client.Error { throw Error.posting(error) } } - private func uploadAttachments() async throws -> [Attachment] { - var attachments: [Attachment] = [] + private func uploadAttachments() async throws -> [String] { + // 2 steps (request data, then upload) for each attachment + self.totalSteps += 2 * draft.attachments.count + + var attachments: [String] = [] attachments.reserveCapacity(draft.attachments.count) for (index, attachment) in draft.draftAttachments.enumerated() { + // if this attachment already exists and is being edited, we don't do anything + // edits to the description are handled as part of the edit status request + if let editedAttachmentID = attachment.editedAttachmentID { + attachments.append(editedAttachmentID) + currentStep += 2 + continue + } + let data: Data let utType: UTType do { @@ -76,7 +115,7 @@ class PostService: ObservableObject { } do { let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription) - attachments.append(uploaded) + attachments.append(uploaded.id) currentStep += 1 } catch let error as Client.Error { throw Error.attachmentUpload(index: index, cause: error) @@ -117,6 +156,17 @@ class PostService: ObservableObject { return text } + // only needed for akkoma, not used on regular mastodon + private func updateEditedAttachments() async { + for attachment in draft.draftAttachments { + guard let id = attachment.editedAttachmentID else { + continue + } + let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil) + _ = try? await mastodonController.run(req) + } + } + enum Error: Swift.Error, LocalizedError { case attachmentData(index: Int, cause: AttachmentData.Error) case attachmentUpload(index: Int, cause: Client.Error) diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift index 2eb2482a..6e0a2cf1 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeMastodonContext.swift @@ -23,4 +23,6 @@ public protocol ComposeMastodonContext { func cachedRelationship(for accountID: String) -> RelationshipProtocol? @MainActor func searchCachedHashtags(query: String) -> [Hashtag] + + func storeCreatedStatus(_ status: Status) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift index bed01edf..e90fcdd5 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift @@ -25,7 +25,7 @@ class AttachmentRowController: ViewController { init(parent: ComposeController, attachment: DraftAttachment) { self.parent = parent self.attachment = attachment - self.thumbnailController = AttachmentThumbnailController(attachment: attachment) + self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent) descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in // the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift index 411c3768..c405fb2f 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift @@ -11,14 +11,16 @@ import Photos import TuskerComponents class AttachmentThumbnailController: ViewController { + unowned let parent: ComposeController let attachment: DraftAttachment @Published private var image: UIImage? @Published private var gifController: GIFController? @Published private var fullSize: Bool = false - init(attachment: DraftAttachment) { + init(attachment: DraftAttachment, parent: ComposeController) { self.attachment = attachment + self.parent = parent } func loadImageIfNecessary(fullSize: Bool) { @@ -28,6 +30,24 @@ class AttachmentThumbnailController: ViewController { self.fullSize = fullSize switch attachment.data { + case .editing(_, let kind, let url): + switch kind { + case .image: + Task { @MainActor in + self.image = await parent.fetchAttachment(url) + } + + case .video, .gifv: + let asset = AVURLAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { + self.image = UIImage(cgImage: cgImage) + } + + case .audio, .unknown: + break + } + case .asset(let id): guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else { return diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 988463c5..106da1e4 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -12,6 +12,7 @@ import TuskerComponents import MatchedGeometryPresentation public final class ComposeController: ViewController { + public typealias FetchAttachment = (URL) async -> UIImage? public typealias FetchStatus = (String) -> (any StatusProtocol)? public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView public typealias CurrentAccountContainerView = (AnyView) -> AnyView @@ -22,6 +23,7 @@ public final class ComposeController: ViewController { @Published public var config: ComposeUIConfig @Published public var mastodonController: ComposeMastodonContext let fetchAvatar: AvatarImageView.FetchAvatar + let fetchAttachment: FetchAttachment let fetchStatus: FetchStatus let displayNameLabel: DisplayNameLabel let currentAccountContainerView: CurrentAccountContainerView @@ -64,11 +66,12 @@ public final class ComposeController: ViewController { } var postButtonEnabled: Bool { - draft.hasContent - && charactersRemaining >= 0 - && !isPosting - && attachmentsListController.isValid - && isPollValid + draft.editedStatusID != nil || + (draft.hasContent + && charactersRemaining >= 0 + && !isPosting + && attachmentsListController.isValid + && isPollValid) } private var isPollValid: Bool { @@ -79,6 +82,8 @@ public final class ComposeController: ViewController { if let id = draft.inReplyToID, let status = fetchStatus(id) { return "Reply to @\(status.account.acct)" + } else if draft.editedStatusID != nil { + return "Edit Post" } else { return "New Post" } @@ -89,6 +94,7 @@ public final class ComposeController: ViewController { config: ComposeUIConfig, mastodonController: ComposeMastodonContext, fetchAvatar: @escaping AvatarImageView.FetchAvatar, + fetchAttachment: @escaping FetchAttachment, fetchStatus: @escaping FetchStatus, displayNameLabel: @escaping DisplayNameLabel, currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 }, @@ -99,6 +105,7 @@ public final class ComposeController: ViewController { self.config = config self.mastodonController = mastodonController self.fetchAvatar = fetchAvatar + self.fetchAttachment = fetchAttachment self.fetchStatus = fetchStatus self.displayNameLabel = displayNameLabel self.currentAccountContainerView = currentAccountContainerView @@ -170,7 +177,7 @@ public final class ComposeController: ViewController { func postStatus() { guard !isPosting, - draft.hasContent else { + draft.editedStatusID != nil || draft.hasContent else { return } @@ -396,20 +403,27 @@ public final class ComposeController: ViewController { .font(.system(size: 17, weight: .regular)) } .confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) { - Button(action: { controller.cancel(deleteDraft: false) }) { - Text("Save Draft") - } - Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) { - Text("Delete Draft") + // edit drafts can't be saved + if draft.editedStatusID == nil { + Button(action: { controller.cancel(deleteDraft: false) }) { + Text("Save Draft") + } + Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) { + Text("Delete Draft") + } + } else { + Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) { + Text("Cancel Edit") + } } } } @ViewBuilder private var postButton: some View { - if draft.hasContent || !controller.config.allowSwitchingDrafts { + if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts { Button(action: controller.postStatus) { - Text("Post") + Text(draft.editedStatusID == nil ? "Post" : "Edit") } .keyboardShortcut(.return, modifiers: .command) .disabled(!controller.postButtonEnabled) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift index 62651c44..13edd88c 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift @@ -119,10 +119,18 @@ class DraftsController: ViewController { private struct DraftRow: View { @ObservedObject var draft: Draft + @EnvironmentObject private var controller: DraftsController var body: some View { HStack { VStack(alignment: .leading) { + if draft.editedStatusID != nil { + // shouldn't happen unless the app crashed/was killed during an edit + Text("Edit") + .font(.body.bold()) + .foregroundColor(.orange) + } + if draft.contentWarningEnabled { Text(draft.contentWarning) .font(.body.bold()) @@ -134,7 +142,7 @@ private struct DraftRow: View { HStack(spacing: 8) { ForEach(draft.draftAttachments) { attachment in - ControllerView(controller: { AttachmentThumbnailController(attachment: attachment) }) + ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) }) .aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 5)) .frame(height: 50) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift index 110e665e..d8cd3d9e 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift @@ -59,10 +59,12 @@ class ToolbarController: ViewController { MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly) // the button has a bunch of extra space by default, but combined with what we add it's too much .padding(.horizontal, -8) + .disabled(draft.editedStatusID != nil) if composeController.mastodonController.instanceFeatures.localOnlyPosts { localOnlyPicker .padding(.horizontal, -8) + .disabled(draft.editedStatusID != nil) } if let currentInput = composeController.currentInput, diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift index e1fbf85a..37923d02 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift @@ -23,6 +23,7 @@ public class Draft: NSManagedObject, Identifiable { @NSManaged public var accountID: String @NSManaged public var contentWarning: String @NSManaged public var contentWarningEnabled: Bool + @NSManaged public var editedStatusID: String? @NSManaged public var id: UUID @NSManaged public var initialText: String @NSManaged public var inReplyToID: String? diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift index c6526cf0..a45057e2 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift @@ -10,6 +10,7 @@ import PencilKit import UniformTypeIdentifiers import Photos import InstanceFeatures +import Pachyderm private let decoder = PropertyListDecoder() private let encoder = PropertyListEncoder() @@ -20,6 +21,9 @@ public final class DraftAttachment: NSManagedObject, Identifiable { @NSManaged internal var assetID: String? @NSManaged public var attachmentDescription: String @NSManaged internal private(set) var drawingData: Data? + @NSManaged public var editedAttachmentID: String? + @NSManaged private var editedAttachmentKindString: String? + @NSManaged public var editedAttachmentURL: URL? @NSManaged public var fileURL: URL? @NSManaged internal var fileType: String? @NSManaged public var id: UUID @@ -41,7 +45,9 @@ public final class DraftAttachment: NSManagedObject, Identifiable { } public var data: AttachmentData { - if let assetID { + if let editedAttachmentID { + return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!) + } else if let assetID { return .asset(assetID) } else if let drawing { return .drawing(drawing) @@ -52,10 +58,20 @@ public final class DraftAttachment: NSManagedObject, Identifiable { } } + public var editedAttachmentKind: Attachment.Kind? { + get { + editedAttachmentKindString.flatMap(Attachment.Kind.init(rawValue:)) + } + set { + editedAttachmentKindString = newValue?.rawValue + } + } + public enum AttachmentData { case asset(String) case drawing(PKDrawing) case file(URL, UTType) + case editing(String, Attachment.Kind, URL) } public override func prepareForDeletion() { @@ -69,7 +85,18 @@ public final class DraftAttachment: NSManagedObject, Identifiable { extension DraftAttachment { var type: AttachmentType { - if let assetID { + if let editedAttachmentKind { + switch editedAttachmentKind { + case .image: + return .image + case .video: + return .video + case .gifv: + return .video + case .audio, .unknown: + return .unknown + } + } else if let assetID { guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else { return .unknown } diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents index 5ed4d3fe..6148bf07 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents @@ -4,6 +4,7 @@ + @@ -19,6 +20,9 @@ + + + diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift index 939a7484..0e549fd6 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift @@ -96,6 +96,67 @@ public class DraftsPersistentContainer: NSPersistentContainer { return draft } + public func createEditDraft( + accountID: String, + source: StatusSource, + inReplyToID: String?, + visibility: Visibility, + localOnly: Bool, + attachments: [Attachment], + poll: Pachyderm.Poll? + ) -> Draft { + let draft = Draft(context: viewContext) + draft.accountID = accountID + draft.editedStatusID = source.id + draft.text = source.text + draft.initialText = source.text + draft.contentWarning = source.spoilerText + draft.contentWarningEnabled = !source.spoilerText.isEmpty + draft.inReplyToID = inReplyToID + draft.visibility = visibility + draft.localOnly = localOnly + for attachment in attachments { + createEditDraftAttachment(attachment, in: draft) + } + if let existingPoll = poll { + let poll = Poll(context: viewContext) + poll.draft = draft + draft.poll = poll + if let expiresAt = existingPoll.expiresAt, + !existingPoll.effectiveExpired { + poll.duration = PollController.Duration.allCases.max(by: { + (expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval) + })!.timeInterval + } else { + poll.duration = PollController.Duration.oneDay.timeInterval + } + poll.multiple = existingPoll.multiple + // rmeove default empty options + for opt in poll.pollOptions { + viewContext.delete(opt) + } + for existingOpt in existingPoll.options { + let opt = PollOption(context: viewContext) + opt.poll = poll + poll.options.add(opt) + opt.text = existingOpt.title + } + } + save() + return draft + } + + private func createEditDraftAttachment(_ attachment: Attachment, in draft: Draft) { + let draftAttachment = DraftAttachment(context: viewContext) + draftAttachment.id = UUID() + draftAttachment.attachmentDescription = attachment.description ?? "" + draftAttachment.editedAttachmentID = attachment.id + draftAttachment.editedAttachmentKind = attachment.kind + draftAttachment.editedAttachmentURL = attachment.url + draftAttachment.draft = draft + draft.attachments.add(draftAttachment) + } + @objc private func remoteChanges(_ notification: Foundation.Notification) { guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else { return diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index a146f89e..09d93d06 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -115,6 +115,15 @@ public class InstanceFeatures: ObservableObject { instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil)) } + public var editStatuses: Bool { + // todo: does this require a particular akkoma version? + hasMastodonVersion(3, 5, 0) || instanceType.isPleroma(.akkoma(nil)) + } + + public var needsEditAttachmentsInSeparateRequest: Bool { + instanceType.isPleroma(.akkoma(nil)) + } + public init() { } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index f50eec41..39a919e8 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -315,6 +315,13 @@ public class Client { ], attachment)) } + public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request { + return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([ + "description" => description, + "focus" => focus + ], nil)) + } + // MARK: - Mutes public static func getMutes(range: RequestRange) -> Request<[Account]> { var request = Request<[Account]>(method: .get, path: "/api/v1/mutes") @@ -382,7 +389,7 @@ public class Client { public static func createStatus(text: String, contentType: StatusContentType = .plain, inReplyTo: String? = nil, - media: [Attachment]? = nil, + mediaIDs: [String]? = nil, sensitive: Bool? = nil, spoilerText: String? = nil, visibility: Visibility? = nil, @@ -402,7 +409,32 @@ public class Client { "poll[expires_in]" => pollExpiresIn, "poll[multiple]" => pollMultiple, "local_only" => localOnly, - ] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions)) + ] + "media_ids" => mediaIDs + "poll[options]" => pollOptions)) + } + + public static func editStatus( + id: String, + text: String, + contentType: StatusContentType = .plain, + spoilerText: String?, + sensitive: Bool, + language: String?, + mediaIDs: [String], + mediaAttributes: [EditStatusMediaAttributes], + poll: EditPollParameters? + ) -> Request { + let params = EditStatusParameters( + id: id, + text: text, + contentType: contentType, + spoilerText: spoilerText, + sensitive: sensitive, + language: language, + mediaIDs: mediaIDs, + mediaAttributes: mediaAttributes, + poll: poll + ) + return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params)) } // MARK: - Timelines diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/EditStatusParameters.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/EditStatusParameters.swift new file mode 100644 index 00000000..73c8da7d --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/EditStatusParameters.swift @@ -0,0 +1,97 @@ +// +// EditStatusParameters.swift +// Pachyderm +// +// Created by Shadowfacts on 5/10/23. +// + +import Foundation + +struct EditStatusParameters: Encodable, Sendable { + let id: String + let text: String + let contentType: StatusContentType + let spoilerText: String? + let sensitive: Bool + let language: String? + let mediaIDs: [String] + let mediaAttributes: [EditStatusMediaAttributes] + let poll: EditPollParameters? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.text, forKey: .text) + try container.encode(self.contentType, forKey: .contentType) + try container.encodeIfPresent(self.spoilerText, forKey: .spoilerText) + try container.encode(self.sensitive, forKey: .sensitive) + try container.encodeIfPresent(self.language, forKey: .language) + try container.encode(self.mediaIDs, forKey: .mediaIDs) + try container.encode(self.mediaAttributes, forKey: .mediaAttributes) + try container.encodeIfPresent(self.poll, forKey: .poll) + } + + enum CodingKeys: String, CodingKey { + case id + case text = "status" + case contentType = "content_type" + case spoilerText = "spoiler_text" + case sensitive + case language + case mediaIDs = "media_ids" + case mediaAttributes = "media_attributes" + case poll + } +} + +public struct EditPollParameters: Encodable, Sendable { + let options: [String] + let expiresIn: Int + let multiple: Bool + + public init(options: [String], expiresIn: Int, multiple: Bool) { + self.options = options + self.expiresIn = expiresIn + self.multiple = multiple + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.options, forKey: .options) + try container.encode(self.expiresIn, forKey: .expiresIn) + try container.encode(self.multiple, forKey: .multiple) + } + + enum CodingKeys: String, CodingKey { + case options + case expiresIn = "expires_in" + case multiple + } +} + +public struct EditStatusMediaAttributes: Encodable, Sendable { + let id: String + let description: String + let focus: (Float, Float)? + + public init(id: String, description: String, focus: (Float, Float)?) { + self.id = id + self.description = description + self.focus = focus + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(description, forKey: .description) + if let focus { + try container.encode("\(focus.0),\(focus.1)", forKey: .focus) + } + } + + enum CodingKeys: String, CodingKey { + case id + case description + case focus + } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index 85d73b6f..cec48c56 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -163,6 +163,10 @@ public final class Status: StatusProtocol, Decodable, Sendable { return Request(method: .post, path: "/api/v1/statuses/\(statusID)/unmute") } + public static func source(_ statusID: String) -> Request { + return Request(method: .get, path: "/api/v1/statuses/\(statusID)/source") + } + private enum CodingKeys: String, CodingKey { case id case uri diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/StatusSource.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/StatusSource.swift new file mode 100644 index 00000000..01f14381 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/StatusSource.swift @@ -0,0 +1,20 @@ +// +// StatusSource.swift +// Pachyderm +// +// Created by Shadowfacts on 5/10/23. +// + +import Foundation + +public struct StatusSource: Decodable { + public let id: String + public let text: String + public let spoilerText: String + + enum CodingKeys: String, CodingKey { + case id + case text + case spoilerText = "spoiler_text" + } +} diff --git a/ShareExtension/ShareHostingController.swift b/ShareExtension/ShareHostingController.swift index 6bcfcdb6..abf9fa10 100644 --- a/ShareExtension/ShareHostingController.swift +++ b/ShareExtension/ShareHostingController.swift @@ -36,6 +36,7 @@ class ShareHostingController: UIHostingController { config: ComposeUIConfig(), mastodonController: mastodonContext, fetchAvatar: Self.fetchAvatar, + fetchAttachment: { _ in fatalError("edits aren't allowed in share sheet, can't fetch existing attachment") }, fetchStatus: { _ in fatalError("replies aren't allowed in share sheet") }, displayNameLabel: { account, style, _ in AnyView(Text(account.displayName).font(.system(style))) }, currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) }, diff --git a/ShareExtension/ShareMastodonContext.swift b/ShareExtension/ShareMastodonContext.swift index 0a99e105..829b34fd 100644 --- a/ShareExtension/ShareMastodonContext.swift +++ b/ShareExtension/ShareMastodonContext.swift @@ -89,4 +89,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject { func searchCachedHashtags(query: String) -> [Hashtag] { return [] } + + func storeCreatedStatus(_ status: Status) { + } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 9b52fa74..92f2f0d4 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -296,6 +296,7 @@ D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; }; D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; }; + D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; @@ -690,6 +691,7 @@ D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = ""; }; D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = ""; }; D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = ""; }; + D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusSourceService.swift; sourceTree = ""; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = ""; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = ""; }; @@ -1596,6 +1598,7 @@ D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */, D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */, D6DD8FFC298495A8002AD3FD /* LogoutService.swift */, + D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */, ); path = API; sourceTree = ""; @@ -2040,6 +2043,7 @@ D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */, D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */, D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */, + D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */, D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */, D61F75B7293C119700C0B37F /* Filterer.swift in Sources */, D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */, diff --git a/Tusker/API/FetchStatusSourceService.swift b/Tusker/API/FetchStatusSourceService.swift new file mode 100644 index 00000000..3982fe84 --- /dev/null +++ b/Tusker/API/FetchStatusSourceService.swift @@ -0,0 +1,26 @@ +// +// FetchStatusSourceService.swift +// Tusker +// +// Created by Shadowfacts on 5/10/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm + +@MainActor +class FetchStatusSourceService { + let statusID: String + let delegate: TuskerNavigationDelegate + + init(statusID: String, delegate: TuskerNavigationDelegate) { + self.statusID = statusID + self.delegate = delegate + } + + func run() async throws -> StatusSource { + // todo: show loading indicator if this takes longer than a certain time + return try await delegate.apiController.run(Status.source(statusID)).0 + } +} diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 059b5392..9c35d8ce 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -506,6 +506,20 @@ class MastodonController: ObservableObject { ) } + func createDraft(editing status: StatusMO, source: StatusSource) -> Draft { + precondition(status.id == source.id) + let draft = DraftsPersistentContainer.shared.createEditDraft( + accountID: accountInfo!.id, + source: source, + inReplyToID: status.inReplyToID, + visibility: status.visibility, + localOnly: status.localOnly, + attachments: status.attachments, + poll: status.poll + ) + return draft + } + } private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) { diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index a4023ec5..e89b9333 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -97,7 +97,7 @@ - + diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 5e3074f7..f7c02118 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -39,6 +39,7 @@ class ComposeHostingController: UIHostingController = .weakObjects() @@ -61,12 +61,15 @@ class AttachmentsContainerView: UIView { // MARK: - User Interaface func updateUI(status: StatusMO) { - guard self.statusID != status.id else { + let showableAttachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) } + let newTokens = showableAttachments.map { AttachmentToken(attachment: $0) } + + guard self.attachmentTokens != newTokens else { return } - self.statusID = status.id - attachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) } + self.attachments = showableAttachments + self.attachmentTokens = newTokens attachmentViews.allObjects.forEach { $0.removeFromSuperview() } attachmentViews.removeAllObjects() @@ -461,3 +464,15 @@ fileprivate extension UIView { return heightAnchor.constraint(equalTo: superview!.heightAnchor, multiplier: 0.5, constant: -spacing / 2) } } + +// A token that represents properties of attachments that the container needs to take into account when deciding whether to update +fileprivate struct AttachmentToken: Equatable { + let url: URL + // to show the alt badge or not + let hasDescription: Bool + + init(attachment: Attachment) { + self.url = attachment.url + self.hasDescription = attachment.description != nil + } +} diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index 5647b0b0..95032103 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -58,11 +58,8 @@ extension StatusCollectionViewCell { mastodonController.persistentContainer.statusSubject .receive(on: DispatchQueue.main) .filter { [unowned self] in $0 == self.statusID } - .sink { [unowned self] in - if let mastodonController = self.mastodonController, - let status = mastodonController.persistentContainer.status(for: $0) { - self.updateStatusState(status: status) - } + .sink { [unowned self] _ in + self.delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil) } .store(in: &cancellables)