Delete and redraft

This commit is contained in:
Justin Mazzocchi 2021-01-11 14:45:30 -08:00
parent 96d96bd899
commit 88c3fedd93
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
15 changed files with 159 additions and 33 deletions

View file

@ -221,6 +221,12 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func delete(id: Status.Id) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher(updates: StatusRecord.filter(StatusRecord.Columns.id == id).deleteAll)
.ignoreOutput()
.eraseToAnyPublisher()
}
func unfollow(id: Account.Id) -> AnyPublisher<Never, Error> { func unfollow(id: Account.Id) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher { databaseWriter.writePublisher {
let statusIds = try Status.Id.fetchAll( let statusIds = try Status.Id.fetchAll(

View file

@ -156,6 +156,8 @@
"share-extension-error.no-account-found" = "No account found"; "share-extension-error.no-account-found" = "No account found";
"status.bookmark" = "Bookmark"; "status.bookmark" = "Bookmark";
"status.content-warning-abbreviation" = "CW"; "status.content-warning-abbreviation" = "CW";
"status.delete" = "Delete";
"status.delete-and-redraft" = "Delete & re-draft";
"status.mute" = "Mute conversation"; "status.mute" = "Mute conversation";
"status.pin" = "Pin on profile"; "status.pin" = "Pin on profile";
"status.pinned-post" = "Pinned post"; "status.pinned-post" = "Pinned post";

View file

@ -17,7 +17,7 @@ public final class Status: Codable, Identifiable {
public let uri: String public let uri: String
public let createdAt: Date public let createdAt: Date
public let account: Account public let account: Account
public let content: HTML @DecodableDefault.EmptyHTML public private(set) var content: HTML
public let visibility: Visibility public let visibility: Visibility
public let sensitive: Bool public let sensitive: Bool
public let spoilerText: String public let spoilerText: String
@ -77,7 +77,6 @@ public final class Status: Codable, Identifiable {
self.uri = uri self.uri = uri
self.createdAt = createdAt self.createdAt = createdAt
self.account = account self.account = account
self.content = content
self.visibility = visibility self.visibility = visibility
self.sensitive = sensitive self.sensitive = sensitive
self.spoilerText = spoilerText self.spoilerText = spoilerText
@ -98,6 +97,7 @@ public final class Status: Codable, Identifiable {
self.text = text self.text = text
self.pinned = pinned self.pinned = pinned
self.repliesCount = repliesCount self.repliesCount = repliesCount
self.content = content
self.favourited = favourited self.favourited = favourited
self.reblogged = reblogged self.reblogged = reblogged
self.muted = muted self.muted = muted

View file

@ -2,8 +2,6 @@
import Foundation import Foundation
// Thank you https://www.swiftbysundell.com/tips/default-decoding-values/
public protocol DecodableDefaultSource { public protocol DecodableDefaultSource {
associatedtype Value: Decodable associatedtype Value: Decodable
static var defaultValue: Value { get } static var defaultValue: Value { get }
@ -40,6 +38,10 @@ public extension DecodableDefault {
public static var defaultValue: String { "" } public static var defaultValue: String { "" }
} }
public enum EmptyHTML: Source {
public static var defaultValue: HTML { HTML(raw: "", attributed: NSAttributedString(string: "")) }
}
public enum EmptyList<T: List>: Source { public enum EmptyList<T: List>: Source {
public static var defaultValue: T { [] } public static var defaultValue: T { [] }
} }
@ -67,6 +69,7 @@ public extension DecodableDefault {
typealias True = Wrapper<Sources.True> typealias True = Wrapper<Sources.True>
typealias False = Wrapper<Sources.False> typealias False = Wrapper<Sources.False>
typealias EmptyString = Wrapper<Sources.EmptyString> typealias EmptyString = Wrapper<Sources.EmptyString>
typealias EmptyHTML = Wrapper<Sources.EmptyHTML>
typealias EmptyList<T: List> = Wrapper<Sources.EmptyList<T>> typealias EmptyList<T: List> = Wrapper<Sources.EmptyList<T>>
typealias EmptyMap<T: Map> = Wrapper<Sources.EmptyMap<T>> typealias EmptyMap<T: Map> = Wrapper<Sources.EmptyMap<T>>
typealias Zero = Wrapper<Sources.Zero> typealias Zero = Wrapper<Sources.Zero>

View file

@ -16,6 +16,7 @@ public enum StatusEndpoint {
case unpin(id: Status.Id) case unpin(id: Status.Id)
case mute(id: Status.Id) case mute(id: Status.Id)
case unmute(id: Status.Id) case unmute(id: Status.Id)
case delete(id: Status.Id)
case post(Components) case post(Components)
} }
@ -100,7 +101,7 @@ extension StatusEndpoint: Endpoint {
public var pathComponentsInContext: [String] { public var pathComponentsInContext: [String] {
switch self { switch self {
case let .status(id): case let .status(id), let .delete(id):
return [id] return [id]
case let .reblog(id): case let .reblog(id):
return [id, "reblog"] return [id, "reblog"]
@ -140,6 +141,8 @@ extension StatusEndpoint: Endpoint {
switch self { switch self {
case .status: case .status:
return .get return .get
case .delete:
return .delete
default: default:
return .post return .post
} }

View file

@ -72,6 +72,34 @@ public extension StatusService {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func delete() -> AnyPublisher<Status, Error> {
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<Self?, Never>
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 { func rebloggedByService() -> AccountListService {
AccountListService( AccountListService(
endpoint: .rebloggedBy(id: status.id), endpoint: .rebloggedBy(id: status.id),

View file

@ -2,6 +2,7 @@
import AVKit import AVKit
import Combine import Combine
import Mastodon
import SafariServices import SafariServices
import SwiftUI import SwiftUI
import ViewModels import ViewModels
@ -248,6 +249,7 @@ private extension TableViewController {
viewModel.alertItems viewModel.alertItems
.compactMap { $0 } .compactMap { $0 }
.handleEvents(receiveOutput: { print($0.error) })
.sink { [weak self] in self?.present(alertItem: $0) } .sink { [weak self] in self?.present(alertItem: $0) }
.store(in: &cancellables) .store(in: &cancellables)
@ -303,8 +305,8 @@ private extension TableViewController {
handle(navigation: navigation) handle(navigation: navigation)
case let .attachment(attachmentViewModel, statusViewModel): case let .attachment(attachmentViewModel, statusViewModel):
present(attachmentViewModel: attachmentViewModel, statusViewModel: statusViewModel) present(attachmentViewModel: attachmentViewModel, statusViewModel: statusViewModel)
case let .reply(statusViewModel): case let .compose(inReplyToViewModel, redraft):
reply(statusViewModel: statusViewModel) compose(inReplyToViewModel: inReplyToViewModel, redraft: redraft)
case let .report(reportViewModel): case let .report(reportViewModel):
report(reportViewModel: 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( let newStatusViewModel = rootViewModel.newStatusViewModel(
identification: identification, identification: identification,
inReplyTo: statusViewModel) inReplyTo: inReplyToViewModel,
redraft: redraft)
let newStatusViewController = UIHostingController(rootView: NewStatusView { newStatusViewModel }) let newStatusViewController = UIHostingController(rootView: NewStatusView { newStatusViewModel })
let navigationController = UINavigationController(rootViewController: newStatusViewController) let navigationController = UINavigationController(rootViewController: newStatusViewController)

View file

@ -8,16 +8,15 @@ import ServiceLayer
public final class CompositionViewModel: AttachmentsRenderingViewModel, ObservableObject, Identifiable { public final class CompositionViewModel: AttachmentsRenderingViewModel, ObservableObject, Identifiable {
public let id = Id() public let id = Id()
public var isPosted = false public var isPosted = false
@Published public var text = "" @Published public var text: String
@Published public var contentWarning = "" @Published public var contentWarning: String
@Published public var displayContentWarning = false @Published public var displayContentWarning: Bool
@Published public var sensitive = false @Published public var sensitive: Bool
@Published public var displayPoll = false @Published public var displayPoll: Bool
@Published public var pollMultipleChoice = false @Published public var pollMultipleChoice: Bool
@Published public var pollHideTotals = false
@Published public var pollExpiresIn = PollExpiry.oneDay @Published public var pollExpiresIn = PollExpiry.oneDay
@Published public private(set) var pollOptions = [PollOption(text: ""), PollOption(text: "")] @Published public private(set) var pollOptions: [PollOption]
@Published public private(set) var attachmentViewModels = [AttachmentViewModel]() @Published public private(set) var attachmentViewModels: [AttachmentViewModel]
@Published public private(set) var attachmentUpload: AttachmentUpload? @Published public private(set) var attachmentUpload: AttachmentUpload?
@Published public private(set) var isPostable = false @Published public private(set) var isPostable = false
@Published public private(set) var canAddAttachment = true @Published public private(set) var canAddAttachment = true
@ -28,8 +27,24 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab
private let eventsSubject: PassthroughSubject<Event, Never> private let eventsSubject: PassthroughSubject<Event, Never>
private var attachmentUploadCancellable: AnyCancellable? private var attachmentUploadCancellable: AnyCancellable?
init(eventsSubject: PassthroughSubject<Event, Never>) { init(eventsSubject: PassthroughSubject<Event, Never>,
redraft: (status: Status, identification: Identification)? = nil) {
self.eventsSubject = eventsSubject 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 } $text.map { !$0.isEmpty }
.removeDuplicates() .removeDuplicates()

View file

@ -1,13 +1,14 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import Foundation import Foundation
import Mastodon
import ServiceLayer import ServiceLayer
public enum CollectionItemEvent { public enum CollectionItemEvent {
case ignorableOutput case ignorableOutput
case navigation(Navigation) case navigation(Navigation)
case attachment(AttachmentViewModel, StatusViewModel) case attachment(AttachmentViewModel, StatusViewModel)
case reply(StatusViewModel) case compose(inReplyTo: StatusViewModel?, redraft: Status?)
case report(ReportViewModel) case report(ReportViewModel)
case share(URL) case share(URL)
} }

View file

@ -26,12 +26,24 @@ public final class NewStatusViewModel: ObservableObject {
public init(allIdentitiesService: AllIdentitiesService, public init(allIdentitiesService: AllIdentitiesService,
identification: Identification, identification: Identification,
environment: AppEnvironment, environment: AppEnvironment,
inReplyTo: StatusViewModel?) { inReplyTo: StatusViewModel?,
redraft: Status?) {
self.allIdentitiesService = allIdentitiesService self.allIdentitiesService = allIdentitiesService
self.identification = identification self.identification = identification
self.environment = environment self.environment = environment
inReplyToViewModel = inReplyTo 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() events = eventsSubject.eraseToAnyPublisher()
visibility = identification.identity.preferences.postingDefaultVisibility visibility = identification.identity.preferences.postingDefaultVisibility
allIdentitiesService.authenticatedIdentitiesPublisher() allIdentitiesService.authenticatedIdentitiesPublisher()

View file

@ -2,6 +2,7 @@
import Combine import Combine
import Foundation import Foundation
import Mastodon
import ServiceLayer import ServiceLayer
public final class RootViewModel: ObservableObject { public final class RootViewModel: ObservableObject {
@ -58,12 +59,16 @@ public extension RootViewModel {
instanceURLService: InstanceURLService(environment: environment)) instanceURLService: InstanceURLService(environment: environment))
} }
func newStatusViewModel(identification: Identification, inReplyTo: StatusViewModel? = nil) -> NewStatusViewModel { func newStatusViewModel(
identification: Identification,
inReplyTo: StatusViewModel? = nil,
redraft: Status? = nil) -> NewStatusViewModel {
NewStatusViewModel( NewStatusViewModel(
allIdentitiesService: allIdentitiesService, allIdentitiesService: allIdentitiesService,
identification: identification, identification: identification,
environment: environment, environment: environment,
inReplyTo: inReplyTo) inReplyTo: inReplyTo,
redraft: redraft)
} }
} }

View file

@ -37,6 +37,7 @@ public extension ShareExtensionNavigationViewModel {
allIdentitiesService: allIdentitiesService, allIdentitiesService: allIdentitiesService,
identification: identification, identification: identification,
environment: environment, environment: environment,
inReplyTo: nil) inReplyTo: nil,
redraft: nil)
} }
} }

View file

@ -213,7 +213,10 @@ public extension StatusViewModel {
replyViewModel.configuration = configuration.reply() 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() { func toggleReblogged() {
@ -251,6 +254,35 @@ public extension StatusViewModel {
.eraseToAnyPublisher()) .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) { func attachmentSelected(viewModel: AttachmentViewModel) {
eventsSubject.send(Just(.attachment(viewModel, self)).setFailureType(to: Error.self).eraseToAnyPublisher()) eventsSubject.send(Just(.attachment(viewModel, self)).setFailureType(to: Error.self).eraseToAnyPublisher())
} }

View file

@ -49,6 +49,7 @@ private extension CompositionPollOptionView {
UIAction { [weak self] _ in UIAction { [weak self] _ in
self?.option.text = textField.text ?? "" }, self?.option.text = textField.text ?? "" },
for: .editingChanged) for: .editingChanged)
textField.text = option.text
stackView.addArrangedSubview(remainingCharactersLabel) stackView.addArrangedSubview(remainingCharactersLabel)
remainingCharactersLabel.adjustsFontForContentSizeCategory = true remainingCharactersLabel.adjustsFontForContentSizeCategory = true

View file

@ -419,13 +419,27 @@ private extension StatusView {
} }
if viewModel.isMine { if viewModel.isMine {
menuItems.append(UIAction( menuItems += [
UIAction(
title: viewModel.muted title: viewModel.muted
? NSLocalizedString("status.unmute", comment: "") ? NSLocalizedString("status.unmute", comment: "")
: NSLocalizedString("status.mute", comment: ""), : NSLocalizedString("status.mute", comment: ""),
image: UIImage(systemName: viewModel.muted ? "speaker" : "speaker.slash")) { _ in image: UIImage(systemName: viewModel.muted ? "speaker" : "speaker.slash")) { _ in
viewModel.toggleMuted() 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 { } else {
menuItems.append(UIAction( menuItems.append(UIAction(
title: NSLocalizedString("report", comment: ""), title: NSLocalizedString("report", comment: ""),