forked from shadowfacts/Tusker
parent
566c3d474d
commit
e87dcfe48e
|
@ -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<Status>
|
||||
|
||||
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)
|
||||
|
|
|
@ -23,4 +23,6 @@ public protocol ComposeMastodonContext {
|
|||
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
|
||||
@MainActor
|
||||
func searchCachedHashtags(query: String) -> [Hashtag]
|
||||
|
||||
func storeCreatedStatus(_ status: Status)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
<attribute name="accountID" attributeType="String"/>
|
||||
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="initialText" attributeType="String"/>
|
||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||
|
@ -19,6 +20,9 @@
|
|||
<attribute name="assetID" optional="YES" attributeType="String"/>
|
||||
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="editedAttachmentID" optional="YES" attributeType="String"/>
|
||||
<attribute name="editedAttachmentKindString" optional="YES" attributeType="String"/>
|
||||
<attribute name="editedAttachmentURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="fileType" optional="YES" attributeType="String"/>
|
||||
<attribute name="fileURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
|
||||
|
|
|
@ -315,6 +315,13 @@ public class Client {
|
|||
], attachment))
|
||||
}
|
||||
|
||||
public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
|
||||
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<Status> {
|
||||
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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -163,6 +163,10 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
||||
}
|
||||
|
||||
public static func source(_ statusID: String) -> Request<StatusSource> {
|
||||
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/source")
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case uri
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -36,6 +36,7 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
|
|||
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)) },
|
||||
|
|
|
@ -89,4 +89,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
|||
func searchCachedHashtags(query: String) -> [Hashtag] {
|
||||
return []
|
||||
}
|
||||
|
||||
func storeCreatedStatus(_ status: Status) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
|
||||
D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = "<group>"; };
|
||||
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusSourceService.swift; sourceTree = "<group>"; };
|
||||
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
|
||||
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
|
||||
|
@ -1596,6 +1598,7 @@
|
|||
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
|
||||
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
|
||||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
||||
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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?) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22C65" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
||||
|
@ -97,7 +97,7 @@
|
|||
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
|
||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="localOnly" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="mentionsData" attributeType="Binary"/>
|
||||
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
|
|
|
@ -39,6 +39,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
config: ComposeUIConfig(),
|
||||
mastodonController: mastodonController,
|
||||
fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
|
||||
fetchAttachment: { @MainActor in await ImageCache.attachments.get($0).1 },
|
||||
fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
|
||||
displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) },
|
||||
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
|
||||
|
@ -210,6 +211,10 @@ extension MastodonController: ComposeMastodonContext {
|
|||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func storeCreatedStatus(_ status: Status) {
|
||||
persistentContainer.addOrUpdate(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeHostingController: PHPickerViewControllerDelegate {
|
||||
|
|
|
@ -300,6 +300,41 @@ extension MenuActionProvider {
|
|||
}))
|
||||
}
|
||||
|
||||
if mastodonController.instanceFeatures.editStatuses {
|
||||
actionsSection.append(UIAction(title: "Edit Post", image: UIImage(systemName: "pencil"), handler: { [weak self] _ in
|
||||
guard let navigationDelegate = self?.navigationDelegate else {
|
||||
return
|
||||
}
|
||||
@MainActor func doEdit() async {
|
||||
let source: StatusSource
|
||||
do {
|
||||
source = try await FetchStatusSourceService(statusID: status.id, delegate: navigationDelegate).run()
|
||||
} catch let error as Client.Error {
|
||||
self?.handleError(error, title: "Error Fetching Source")
|
||||
return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
let draft = navigationDelegate.apiController.createDraft(editing: status, source: source)
|
||||
navigationDelegate.compose(editing: draft)
|
||||
}
|
||||
if status.poll != nil {
|
||||
let alert = UIAlertController(title: "Edit Post with Poll?", message: "This will remove any votes that have already been placed.", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Edit", style: .default, handler: { _ in
|
||||
Task {
|
||||
await doEdit()
|
||||
}
|
||||
}))
|
||||
navigationDelegate.present(alert, animated: true)
|
||||
} else {
|
||||
Task {
|
||||
await doEdit()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
actionsSection.append(UIMenu(title: "Delete Post", image: UIImage(systemName: "trash"), children: [
|
||||
UIAction(title: "Cancel", handler: { _ in }),
|
||||
UIAction(title: "Delete Post", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] _ in
|
||||
|
|
|
@ -15,7 +15,7 @@ class AttachmentsContainerView: UIView {
|
|||
|
||||
weak var delegate: AttachmentViewDelegate?
|
||||
|
||||
var statusID: String!
|
||||
private var attachmentTokens: [AttachmentToken] = []
|
||||
var attachments: [Attachment]!
|
||||
|
||||
let attachmentViews: NSHashTable<AttachmentView> = .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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
Loading…
Reference in New Issue