diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index ef45334..4c30cc9 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -221,6 +221,12 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func delete(id: Status.Id) -> AnyPublisher { + databaseWriter.writePublisher(updates: StatusRecord.filter(StatusRecord.Columns.id == id).deleteAll) + .ignoreOutput() + .eraseToAnyPublisher() + } + func unfollow(id: Account.Id) -> AnyPublisher { databaseWriter.writePublisher { let statusIds = try Status.Id.fetchAll( diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 968e093..a597d44 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -156,6 +156,8 @@ "share-extension-error.no-account-found" = "No account found"; "status.bookmark" = "Bookmark"; "status.content-warning-abbreviation" = "CW"; +"status.delete" = "Delete"; +"status.delete-and-redraft" = "Delete & re-draft"; "status.mute" = "Mute conversation"; "status.pin" = "Pin on profile"; "status.pinned-post" = "Pinned post"; diff --git a/Mastodon/Sources/Mastodon/Entities/Status.swift b/Mastodon/Sources/Mastodon/Entities/Status.swift index 303f43a..6e8f3fe 100644 --- a/Mastodon/Sources/Mastodon/Entities/Status.swift +++ b/Mastodon/Sources/Mastodon/Entities/Status.swift @@ -17,7 +17,7 @@ public final class Status: Codable, Identifiable { public let uri: String public let createdAt: Date public let account: Account - public let content: HTML + @DecodableDefault.EmptyHTML public private(set) var content: HTML public let visibility: Visibility public let sensitive: Bool public let spoilerText: String @@ -77,7 +77,6 @@ public final class Status: Codable, Identifiable { self.uri = uri self.createdAt = createdAt self.account = account - self.content = content self.visibility = visibility self.sensitive = sensitive self.spoilerText = spoilerText @@ -98,6 +97,7 @@ public final class Status: Codable, Identifiable { self.text = text self.pinned = pinned self.repliesCount = repliesCount + self.content = content self.favourited = favourited self.reblogged = reblogged self.muted = muted diff --git a/Mastodon/Sources/Mastodon/Property Wrappers/DecodableDefault.swift b/Mastodon/Sources/Mastodon/Property Wrappers/DecodableDefault.swift index 3b30907..22a6993 100644 --- a/Mastodon/Sources/Mastodon/Property Wrappers/DecodableDefault.swift +++ b/Mastodon/Sources/Mastodon/Property Wrappers/DecodableDefault.swift @@ -2,8 +2,6 @@ import Foundation -// Thank you https://www.swiftbysundell.com/tips/default-decoding-values/ - public protocol DecodableDefaultSource { associatedtype Value: Decodable static var defaultValue: Value { get } @@ -40,6 +38,10 @@ public extension DecodableDefault { public static var defaultValue: String { "" } } + public enum EmptyHTML: Source { + public static var defaultValue: HTML { HTML(raw: "", attributed: NSAttributedString(string: "")) } + } + public enum EmptyList: Source { public static var defaultValue: T { [] } } @@ -67,6 +69,7 @@ public extension DecodableDefault { typealias True = Wrapper typealias False = Wrapper typealias EmptyString = Wrapper + typealias EmptyHTML = Wrapper typealias EmptyList = Wrapper> typealias EmptyMap = Wrapper> typealias Zero = Wrapper diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift index 0aba948..27dd60c 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift @@ -16,6 +16,7 @@ public enum StatusEndpoint { case unpin(id: Status.Id) case mute(id: Status.Id) case unmute(id: Status.Id) + case delete(id: Status.Id) case post(Components) } @@ -100,7 +101,7 @@ extension StatusEndpoint: Endpoint { public var pathComponentsInContext: [String] { switch self { - case let .status(id): + case let .status(id), let .delete(id): return [id] case let .reblog(id): return [id, "reblog"] @@ -140,6 +141,8 @@ extension StatusEndpoint: Endpoint { switch self { case .status: return .get + case .delete: + return .delete default: return .post } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift b/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift index 6aefba2..280437c 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift @@ -72,6 +72,34 @@ public extension StatusService { .eraseToAnyPublisher() } + func delete() -> AnyPublisher { + mastodonAPIClient.request(StatusEndpoint.delete(id: status.displayStatus.id)) + .flatMap { status in contentDatabase.delete(id: status.id).collect().map { _ in status } } + .eraseToAnyPublisher() + } + + func deleteAndRedraft() -> AnyPublisher<(Status, Self?), Error> { + let inReplyToPublisher: AnyPublisher + + if let inReplyToId = status.displayStatus.inReplyToId { + inReplyToPublisher = mastodonAPIClient.request(StatusEndpoint.status(id: inReplyToId)) + .map { + Self(status: $0, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) as Self? + } + .replaceError(with: nil) + .eraseToAnyPublisher() + } else { + inReplyToPublisher = Just(nil).eraseToAnyPublisher() + } + + return mastodonAPIClient.request(StatusEndpoint.delete(id: status.displayStatus.id)) + .flatMap { status in contentDatabase.delete(id: status.id).collect().map { _ in status } } + .zip(inReplyToPublisher.setFailureType(to: Error.self)) + .eraseToAnyPublisher() + } + func rebloggedByService() -> AccountListService { AccountListService( endpoint: .rebloggedBy(id: status.id), diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index bb247ed..a4ebaf4 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -2,6 +2,7 @@ import AVKit import Combine +import Mastodon import SafariServices import SwiftUI import ViewModels @@ -248,6 +249,7 @@ private extension TableViewController { viewModel.alertItems .compactMap { $0 } + .handleEvents(receiveOutput: { print($0.error) }) .sink { [weak self] in self?.present(alertItem: $0) } .store(in: &cancellables) @@ -303,8 +305,8 @@ private extension TableViewController { handle(navigation: navigation) case let .attachment(attachmentViewModel, statusViewModel): present(attachmentViewModel: attachmentViewModel, statusViewModel: statusViewModel) - case let .reply(statusViewModel): - reply(statusViewModel: statusViewModel) + case let .compose(inReplyToViewModel, redraft): + compose(inReplyToViewModel: inReplyToViewModel, redraft: redraft) case let .report(reportViewModel): report(reportViewModel: reportViewModel) } @@ -374,10 +376,11 @@ private extension TableViewController { } } - func reply(statusViewModel: StatusViewModel) { + func compose(inReplyToViewModel: StatusViewModel?, redraft: Status?) { let newStatusViewModel = rootViewModel.newStatusViewModel( identification: identification, - inReplyTo: statusViewModel) + inReplyTo: inReplyToViewModel, + redraft: redraft) let newStatusViewController = UIHostingController(rootView: NewStatusView { newStatusViewModel }) let navigationController = UINavigationController(rootViewController: newStatusViewController) diff --git a/ViewModels/Sources/ViewModels/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/CompositionViewModel.swift index ab2f939..61c9fa4 100644 --- a/ViewModels/Sources/ViewModels/CompositionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CompositionViewModel.swift @@ -8,16 +8,15 @@ import ServiceLayer public final class CompositionViewModel: AttachmentsRenderingViewModel, ObservableObject, Identifiable { public let id = Id() public var isPosted = false - @Published public var text = "" - @Published public var contentWarning = "" - @Published public var displayContentWarning = false - @Published public var sensitive = false - @Published public var displayPoll = false - @Published public var pollMultipleChoice = false - @Published public var pollHideTotals = false + @Published public var text: String + @Published public var contentWarning: String + @Published public var displayContentWarning: Bool + @Published public var sensitive: Bool + @Published public var displayPoll: Bool + @Published public var pollMultipleChoice: Bool @Published public var pollExpiresIn = PollExpiry.oneDay - @Published public private(set) var pollOptions = [PollOption(text: ""), PollOption(text: "")] - @Published public private(set) var attachmentViewModels = [AttachmentViewModel]() + @Published public private(set) var pollOptions: [PollOption] + @Published public private(set) var attachmentViewModels: [AttachmentViewModel] @Published public private(set) var attachmentUpload: AttachmentUpload? @Published public private(set) var isPostable = false @Published public private(set) var canAddAttachment = true @@ -28,8 +27,24 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab private let eventsSubject: PassthroughSubject private var attachmentUploadCancellable: AnyCancellable? - init(eventsSubject: PassthroughSubject) { + init(eventsSubject: PassthroughSubject, + redraft: (status: Status, identification: Identification)? = nil) { self.eventsSubject = eventsSubject + text = redraft?.status.text ?? "" + contentWarning = redraft?.status.spoilerText ?? "" + displayContentWarning = !(redraft?.status.spoilerText.isEmpty ?? true) + sensitive = redraft?.status.sensitive ?? false + displayPoll = redraft?.status.poll != nil + pollMultipleChoice = redraft?.status.poll?.multiple ?? false + pollOptions = redraft?.status.poll?.options.map { PollOption(text: $0.title) } + ?? [PollOption(text: ""), PollOption(text: "")] + if let redraft = redraft { + attachmentViewModels = redraft.status.mediaAttachments.map { + AttachmentViewModel(attachment: $0, identification: redraft.identification) + } + } else { + attachmentViewModels = [AttachmentViewModel]() + } $text.map { !$0.isEmpty } .removeDuplicates() diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift index 50f2620..4f72c4c 100644 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift @@ -1,13 +1,14 @@ // Copyright © 2020 Metabolist. All rights reserved. import Foundation +import Mastodon import ServiceLayer public enum CollectionItemEvent { case ignorableOutput case navigation(Navigation) case attachment(AttachmentViewModel, StatusViewModel) - case reply(StatusViewModel) + case compose(inReplyTo: StatusViewModel?, redraft: Status?) case report(ReportViewModel) case share(URL) } diff --git a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift index 9b526ae..84fbc0f 100644 --- a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift @@ -26,12 +26,24 @@ public final class NewStatusViewModel: ObservableObject { public init(allIdentitiesService: AllIdentitiesService, identification: Identification, environment: AppEnvironment, - inReplyTo: StatusViewModel?) { + inReplyTo: StatusViewModel?, + redraft: Status?) { self.allIdentitiesService = allIdentitiesService self.identification = identification self.environment = environment inReplyToViewModel = inReplyTo - compositionViewModels = [CompositionViewModel(eventsSubject: compositionEventsSubject)] + + let redraftAndIdentification: (status: Status, identification: Identification)? + + if let redraft = redraft { + redraftAndIdentification = (status: redraft, identification: identification) + } else { + redraftAndIdentification = nil + } + + compositionViewModels = [CompositionViewModel( + eventsSubject: compositionEventsSubject, + redraft: redraftAndIdentification)] events = eventsSubject.eraseToAnyPublisher() visibility = identification.identity.preferences.postingDefaultVisibility allIdentitiesService.authenticatedIdentitiesPublisher() diff --git a/ViewModels/Sources/ViewModels/RootViewModel.swift b/ViewModels/Sources/ViewModels/RootViewModel.swift index 031362a..a02f40d 100644 --- a/ViewModels/Sources/ViewModels/RootViewModel.swift +++ b/ViewModels/Sources/ViewModels/RootViewModel.swift @@ -2,6 +2,7 @@ import Combine import Foundation +import Mastodon import ServiceLayer public final class RootViewModel: ObservableObject { @@ -58,12 +59,16 @@ public extension RootViewModel { instanceURLService: InstanceURLService(environment: environment)) } - func newStatusViewModel(identification: Identification, inReplyTo: StatusViewModel? = nil) -> NewStatusViewModel { + func newStatusViewModel( + identification: Identification, + inReplyTo: StatusViewModel? = nil, + redraft: Status? = nil) -> NewStatusViewModel { NewStatusViewModel( allIdentitiesService: allIdentitiesService, identification: identification, environment: environment, - inReplyTo: inReplyTo) + inReplyTo: inReplyTo, + redraft: redraft) } } diff --git a/ViewModels/Sources/ViewModels/ShareExtensionNavigationViewModel.swift b/ViewModels/Sources/ViewModels/ShareExtensionNavigationViewModel.swift index ca1884b..6238ef0 100644 --- a/ViewModels/Sources/ViewModels/ShareExtensionNavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/ShareExtensionNavigationViewModel.swift @@ -37,6 +37,7 @@ public extension ShareExtensionNavigationViewModel { allIdentitiesService: allIdentitiesService, identification: identification, environment: environment, - inReplyTo: nil) + inReplyTo: nil, + redraft: nil) } } diff --git a/ViewModels/Sources/ViewModels/StatusViewModel.swift b/ViewModels/Sources/ViewModels/StatusViewModel.swift index 3786c0e..5635d68 100644 --- a/ViewModels/Sources/ViewModels/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusViewModel.swift @@ -213,7 +213,10 @@ public extension StatusViewModel { replyViewModel.configuration = configuration.reply() - eventsSubject.send(Just(.reply(replyViewModel)).setFailureType(to: Error.self).eraseToAnyPublisher()) + eventsSubject.send( + Just(.compose(inReplyTo: replyViewModel, redraft: nil)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher()) } func toggleReblogged() { @@ -251,6 +254,35 @@ public extension StatusViewModel { .eraseToAnyPublisher()) } + func delete() { + eventsSubject.send( + statusService.delete() + .map { _ in .ignorableOutput } + .eraseToAnyPublisher()) + } + + func deleteAndRedraft() { + let identification = self.identification + + eventsSubject.send( + statusService.deleteAndRedraft() + .map { redraft, inReplyToStatusService in + let inReplyToViewModel: StatusViewModel? + + if let inReplyToStatusService = inReplyToStatusService { + inReplyToViewModel = Self( + statusService: inReplyToStatusService, + identification: identification) + inReplyToViewModel?.configuration = CollectionItem.StatusConfiguration.default.reply() + } else { + inReplyToViewModel = nil + } + + return .compose(inReplyTo: inReplyToViewModel, redraft: redraft) + } + .eraseToAnyPublisher()) + } + func attachmentSelected(viewModel: AttachmentViewModel) { eventsSubject.send(Just(.attachment(viewModel, self)).setFailureType(to: Error.self).eraseToAnyPublisher()) } diff --git a/Views/CompositionPollOptionView.swift b/Views/CompositionPollOptionView.swift index 3f797b3..5d22169 100644 --- a/Views/CompositionPollOptionView.swift +++ b/Views/CompositionPollOptionView.swift @@ -49,6 +49,7 @@ private extension CompositionPollOptionView { UIAction { [weak self] _ in self?.option.text = textField.text ?? "" }, for: .editingChanged) + textField.text = option.text stackView.addArrangedSubview(remainingCharactersLabel) remainingCharactersLabel.adjustsFontForContentSizeCategory = true diff --git a/Views/Status/StatusView.swift b/Views/Status/StatusView.swift index f133f9b..5fb875a 100644 --- a/Views/Status/StatusView.swift +++ b/Views/Status/StatusView.swift @@ -419,13 +419,27 @@ private extension StatusView { } if viewModel.isMine { - menuItems.append(UIAction( - title: viewModel.muted - ? NSLocalizedString("status.unmute", comment: "") - : NSLocalizedString("status.mute", comment: ""), - image: UIImage(systemName: viewModel.muted ? "speaker" : "speaker.slash")) { _ in - viewModel.toggleMuted() - }) + menuItems += [ + UIAction( + title: viewModel.muted + ? NSLocalizedString("status.unmute", comment: "") + : NSLocalizedString("status.mute", comment: ""), + image: UIImage(systemName: viewModel.muted ? "speaker" : "speaker.slash")) { _ in + viewModel.toggleMuted() + }, + UIAction( + title: NSLocalizedString("status.delete", comment: ""), + image: UIImage(systemName: "trash"), + attributes: .destructive) { _ in + viewModel.delete() + }, + UIAction( + title: NSLocalizedString("status.delete-and-redraft", comment: ""), + image: UIImage(systemName: "trash.circle"), + attributes: .destructive) { _ in + viewModel.deleteAndRedraft() + } + ] } else { menuItems.append(UIAction( title: NSLocalizedString("report", comment: ""),