From fbbcb0d07d68007a42604962acd43c01f3de9296 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 27 Jan 2025 15:42:26 -0500 Subject: [PATCH] Drafts list --- .../Controllers/ComposeController.swift | 6 +- .../Views/AttachmentThumbnailView.swift | 24 ++- .../Sources/ComposeUI/Views/ComposeView.swift | 23 ++- .../ComposeUI/Views/DraftContentEditor.swift | 3 - .../Sources/ComposeUI/Views/DraftsView.swift | 158 ++++++++++++++++++ .../TuskerPreferences/Keys/AdvancedKeys.swift | 4 +- 6 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 91e3a856..ddfeb0d6 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -246,7 +246,9 @@ public final class ComposeController: ViewController { let oldDraft = self.draft self.draft = newDraft - if !oldDraft.hasContent { + if oldDraft.hasContent { + oldDraft.lastModified = Date() + } else if oldDraft.hasContent { DraftsPersistentContainer.shared.viewContext.delete(oldDraft) } DraftsPersistentContainer.shared.save() @@ -256,6 +258,8 @@ public final class ComposeController: ViewController { isDisappearing = true if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) { DraftsPersistentContainer.shared.viewContext.delete(draft) + } else { + draft.lastModified = Date() } DraftsPersistentContainer.shared.save() } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift index 84e8d90d..226fb9c2 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift @@ -13,10 +13,15 @@ import Photos struct AttachmentThumbnailView: View { let attachment: DraftAttachment var contentMode: ContentMode = .fit - + var thumbnailSize: CGSize? + var body: some View { - AttachmentThumbnailViewContent(attachment: attachment, contentMode: contentMode) - .id(attachment.id) + AttachmentThumbnailViewContent( + attachment: attachment, + contentMode: contentMode, + thumbnailSize: thumbnailSize + ) + .id(attachment.id) } } @@ -24,6 +29,7 @@ struct AttachmentThumbnailView: View { private struct AttachmentThumbnailViewContent: View { var attachment: DraftAttachment var contentMode: ContentMode = .fit + var thumbnailSize: CGSize? @State private var mode: Mode = .empty @EnvironmentObject private var composeController: ComposeController @@ -78,7 +84,7 @@ private struct AttachmentThumbnailViewContent: View { } } } else { - let size = CGSize(width: 80, height: 80) + let size = thumbnailSize ?? CGSize(width: 80, height: 80) PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in if let image { self.mode = .image(image) @@ -99,7 +105,7 @@ private struct AttachmentThumbnailViewContent: View { if let image = UIImage(data: data), // using prepareThumbnail on images from PHPicker results in extremely high memory usage, // crashing share extension. see FB12186346 - let prepared = await image.byPreparingForDisplay() { + let prepared = await thumbnailImage(image) { self.mode = .image(prepared) } } @@ -110,6 +116,14 @@ private struct AttachmentThumbnailViewContent: View { } } + private func thumbnailImage(_ image: UIImage) async -> UIImage? { + if let thumbnailSize { + await image.byPreparingThumbnail(ofSize: thumbnailSize) + } else { + await image.byPreparingForDisplay() + } + } + private func loadVideoThumbnail(url: URL) async { let asset = AVURLAsset(url: url) let imageGenerator = AVAssetImageGenerator(asset: asset) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index e761ecc5..d5ee53d8 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -13,10 +13,19 @@ struct ComposeView: View { @State private var poster: PostService? = nil @FocusState private var focusedField: FocusableField? @EnvironmentObject private var controller: ComposeController + @State private var isShowingDrafts = false var body: some View { navigation .environmentObject(mastodonController.instanceFeatures) + .sheet(isPresented: $isShowingDrafts) { + DraftsView( + currentDraft: draft, + isShowingDrafts: $isShowingDrafts, + accountInfo: mastodonController.accountInfo!, + selectDraft: self.selectDraft + ) + } } @ViewBuilder @@ -71,7 +80,7 @@ struct ComposeView: View { .modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarActions(draft: draft, controller: controller) + ToolbarActions(draft: draft, controller: controller, isShowingDrafts: $isShowingDrafts) #if os(visionOS) ToolbarItem(placement: .bottomOrnament) { toolbarView @@ -97,6 +106,11 @@ struct ComposeView: View { private func addAttachments(_ itemProviders: [NSItemProvider]) { AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders) } + + private func selectDraft(_ draft: Draft) { + controller.selectDraft(draft) + isShowingDrafts = false + } } private struct NavigationTitleModifier: ViewModifier { @@ -132,8 +146,9 @@ public struct NavigationTitlePreferenceKey: PreferenceKey { private struct ToolbarActions: ToolbarContent { @ObservedObject var draft: Draft // Prior to iOS 16, the toolbar content doesn't seem to have access - // to the environment form the containing view. + // to the environment from the containing view. let controller: ComposeController + @Binding var isShowingDrafts: Bool var body: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) } @@ -147,7 +162,9 @@ private struct ToolbarActions: ToolbarContent { } private var draftsButton: some View { - Button(action: controller.showDrafts) { + Button { + isShowingDrafts = true + } label: { Text("Drafts") } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift index 0777cf00..df723aa6 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift @@ -67,9 +67,6 @@ private struct LanguageButton: View { LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged) .buttonStyle(LanguageButtonStyle()) .onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged) - .onChange(of: draft.id) { _ in - hasChanged = false - } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift new file mode 100644 index 00000000..503fc416 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift @@ -0,0 +1,158 @@ +// +// DraftsView.swift +// ComposeUI +// +// Created by Shadowfacts on 1/27/25. +// + +import SwiftUI +import UserAccounts + +struct DraftsView: View { + let currentDraft: Draft + @Binding var isShowingDrafts: Bool + let accountInfo: UserAccountInfo + let selectDraft: (Draft) -> Void + @State private var draftForDifferentReply: Draft? = nil + + var body: some View { + navigationView + .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in + Button(role: .cancel) { + draftForDifferentReply = nil + } label: { + Text("Cancel") + } + Button { + selectDraft(draft) + } label: { + Text("Restore Draft") + } + } message: { _ in + Text("The selected draft is a reply to a different post, do you wish to use it?") + } + } + + @ViewBuilder + private var navigationView: some View { + if #available(iOS 16.0, *) { + NavigationStack { + navigationRoot + } + } else { + NavigationView { + navigationRoot + } + .navigationViewStyle(.stack) + } + } + + private var navigationRoot: some View { + DraftsListView(currentDraft: currentDraft, isShowingDrafts: $isShowingDrafts, accountInfo: accountInfo, selectDraft: selectDraft) + .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext) + } +} + +private struct DraftsListView: View { + let currentDraft: Draft + @Binding var isShowingDrafts: Bool + let accountInfo: UserAccountInfo + let selectDraft: (Draft) -> Void + @FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults + @Environment(\.composeUIConfig.userActivityForDraft) private var userActivityForDraft + + var body: some View { + List { + ForEach(drafts) { draft in + Button { + self.selectDraft(draft) + } label: { + DraftRowView(draft: draft) + } + .contextMenu { + Button(role: .destructive) { + deleteDraft(draft) + } label: { + Label("Delete Draft", systemImage: "trash") + } + } + .onDrag { + userActivityForDraft(draft) ?? NSItemProvider() + } + } + .onDelete { indices in + indices.map { drafts[$0] }.forEach(deleteDraft) + } + } + .listStyle(.plain) + .navigationTitle("Drafts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + cancelButton + } + } + .onAppear { + drafts.nsPredicate = NSPredicate( + format: "accountID == %@ AND id != %@ AND lastModified != nil", + accountInfo.id, + currentDraft.id as NSUUID + ) + } + } + + private var cancelButton: some View { + Button { + isShowingDrafts = false + } label: { + Text("Cancel") + } + } + + private func deleteDraft(_ draft: Draft) { + DraftsPersistentContainer.shared.viewContext.delete(draft) + } +} + +private struct DraftRowView: View { + @ObservedObject var draft: Draft + + 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()) + .foregroundStyle(.orange) + } + + if draft.contentWarningEnabled && !draft.contentWarning.isEmpty { + Text(draft.contentWarning) + .font(.body.bold()) + .foregroundStyle(.secondary) + } + + Text(draft.text) + .font(.body) + + HStack(spacing: 8) { + ForEach(draft.draftAttachments) { attachment in + AttachmentThumbnailView(attachment: attachment, thumbnailSize: CGSize(width: 50, height: 50)) + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .frame(height: 50) + } + } + } + + Spacer() + + if let lastModified = draft.lastModified { + Text(lastModified.formatted(.abbreviatedTimeAgo)) + .font(.body) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AdvancedKeys.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AdvancedKeys.swift index 7baf2249..6751fc11 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AdvancedKeys.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AdvancedKeys.swift @@ -8,8 +8,8 @@ import Foundation import Pachyderm -struct StatusContentTypeKey: MigratablePreferenceKey { - static var defaultValue: StatusContentType { .plain } +public struct StatusContentTypeKey: MigratablePreferenceKey { + public static var defaultValue: StatusContentType { .plain } } struct FeatureFlagsKey: MigratablePreferenceKey, CustomCodablePreferenceKey {