diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 22e22a0..a9d0000 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -24,6 +24,12 @@ "account.hide-reblogs" = "Hide boosts"; "account.locked.accessibility-label" = "Locked account"; "account.mute" = "Mute"; +"account.mute.indefinite" = "Indefnite"; +"account.mute.confirm-%@" = "Are you sure you want to mute %@?"; +"account.mute.confirm.explanation" = "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you."; +"account.mute.confirm.hide-notifications" = "Hide notifications from this user?"; +"account.mute.confirm.duration" = "Duration"; +"account.mute.target-%@" = "Muting %@"; "account.reject-follow-request-button.accessibility-label" = "Reject follow request"; "account.request" = "Request"; "account.request.cancel" = "Cancel follow request"; @@ -38,6 +44,7 @@ "account.unblock.confirm-%@" = "Unblock %@?"; "account.unfollow" = "Unfollow"; "account.unmute" = "Unmute"; +"account.unmute.confirm-%@" = "Unmute %@?"; "activity.open-in-default-browser" = "Open in default browser"; "add" = "Add"; "apns-default-message" = "New notification"; diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift index e830eb5..ff8411c 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift @@ -9,7 +9,7 @@ public enum RelationshipEndpoint { case accountsUnfollow(id: Account.Id) case accountsBlock(id: Account.Id) case accountsUnblock(id: Account.Id) - case accountsMute(id: Account.Id) + case accountsMute(id: Account.Id, notifications: Bool = true, duration: Int = 0) case accountsUnmute(id: Account.Id) case accountsPin(id: Account.Id) case accountsUnpin(id: Account.Id) @@ -40,7 +40,7 @@ extension RelationshipEndpoint: Endpoint { return [id, "block"] case let .accountsUnblock(id): return [id, "unblock"] - case let .accountsMute(id): + case let .accountsMute(id, _, _): return [id, "mute"] case let .accountsUnmute(id): return [id, "unmute"] @@ -72,6 +72,8 @@ extension RelationshipEndpoint: Endpoint { public var jsonBody: [String: Any]? { switch self { + case let .accountsMute(_, notifications, duration): + return ["notifications": notifications, "duration": duration] case let .note(note, _): return ["comment": note] default: diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index b13833a..a54f48c 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ D07EC7FE25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */; }; D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; }; D07EC81225B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; }; + D07F4D9825D493E300F61133 /* MuteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07F4D9725D493E300F61133 /* MuteView.swift */; }; D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; }; D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D087671525BAA8C0001FDD43 /* ExploreViewController.swift */; }; D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; }; @@ -292,6 +293,7 @@ D07EC7F125B13E57006DF726 /* EmojiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiView.swift; sourceTree = ""; }; D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCategoryHeaderView.swift; sourceTree = ""; }; D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemEmoji+Extensions.swift"; sourceTree = ""; }; + D07F4D9725D493E300F61133 /* MuteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteView.swift; sourceTree = ""; }; D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = ""; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = ""; }; D087671525BAA8C0001FDD43 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = ""; }; @@ -487,6 +489,7 @@ D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, + D07F4D9725D493E300F61133 /* MuteView.swift */, D08B9F0F25CB8E060062D040 /* NotificationPreferencesView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */, @@ -1015,6 +1018,7 @@ D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0477F2C25C6EBAD005C5368 /* OpenInDefaultBrowserActivity.swift in Sources */, D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */, + D07F4D9825D493E300F61133 /* MuteView.swift in Sources */, D0477F1525C68BAC005C5368 /* PrefetchRequestModifier.swift in Sources */, D097F41B25BE3E1A00859F2C /* SearchScope+Extensions.swift in Sources */, D035F8B325B9616000DC75ED /* Timeline+Extensions.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift index a7627c7..9dfeef0 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift @@ -62,8 +62,8 @@ public extension AccountService { relationshipAction(.accountsUnblock(id: account.id)) } - func mute() -> AnyPublisher { - relationshipAction(.accountsMute(id: account.id)) + func mute(notifications: Bool, duration: Int) -> AnyPublisher { + relationshipAction(.accountsMute(id: account.id, notifications: notifications, duration: duration)) .collect() .flatMap { _ in contentDatabase.mute(id: account.id) } .eraseToAnyPublisher() diff --git a/View Controllers/ProfileViewController.swift b/View Controllers/ProfileViewController.swift index 0236d91..16ffca5 100644 --- a/View Controllers/ProfileViewController.swift +++ b/View Controllers/ProfileViewController.swift @@ -95,13 +95,13 @@ private extension ProfileViewController { actions.append(UIAction( title: NSLocalizedString("account.unmute", comment: ""), image: UIImage(systemName: "speaker")) { _ in - accountViewModel.unmute() + accountViewModel.confirmUnmute() }) } else { actions.append(UIAction( title: NSLocalizedString("account.mute", comment: ""), image: UIImage(systemName: "speaker.slash")) { _ in - accountViewModel.mute() + accountViewModel.confirmMute() }) } diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 0f77828..138fb21 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -443,6 +443,10 @@ private extension TableViewController { compose(inReplyToViewModel: inReplyToViewModel, redraft: redraft) case let .confirmDelete(statusViewModel, redraft): confirmDelete(statusViewModel: statusViewModel, redraft: redraft) + case let .confirmMute(accountViewModel): + confirmMute(muteViewModel: accountViewModel.muteViewModel()) + case let .confirmUnmute(accountViewModel): + confirmUnmute(accountViewModel: accountViewModel) case let .confirmBlock(accountViewModel): confirmBlock(accountViewModel: accountViewModel) case let .confirmUnblock(accountViewModel): @@ -571,6 +575,21 @@ private extension TableViewController { present(alertController, animated: true) } + func confirmMute(muteViewModel: MuteViewModel) { + let muteViewController = MuteViewController(viewModel: muteViewModel) + let navigationController = UINavigationController(rootViewController: muteViewController) + + present(navigationController, animated: true) + } + + func confirmUnmute(accountViewModel: AccountViewModel) { + confirm(message: String.localizedStringWithFormat( + NSLocalizedString("account.unmute.confirm-%@", comment: ""), + accountViewModel.accountName)) { + accountViewModel.unmute() + } + } + func confirmBlock(accountViewModel: AccountViewModel) { let alertController = UIAlertController( title: nil, diff --git a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift index 2d24863..8c855fa 100644 --- a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift +++ b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift @@ -85,6 +85,15 @@ public extension ReportViewModel { identityContext: .preview) } +public extension MuteViewModel { + static let preview = MuteViewModel( + accountService: AccountService( + account: .preview, + mastodonAPIClient: .preview, + contentDatabase: .preview), + identityContext: .preview) +} + public extension DomainBlocksViewModel { static let preview = DomainBlocksViewModel(service: .init(mastodonAPIClient: .preview)) } diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift index cef43b6..65ca13c 100644 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift @@ -11,6 +11,8 @@ public enum CollectionItemEvent { case attachment(AttachmentViewModel, StatusViewModel) case compose(inReplyTo: StatusViewModel?, redraft: Status?) case confirmDelete(StatusViewModel, redraft: Bool) + case confirmMute(AccountViewModel) + case confirmUnmute(AccountViewModel) case confirmBlock(AccountViewModel) case confirmUnblock(AccountViewModel) case confirmDomainBlock(AccountViewModel) diff --git a/ViewModels/Sources/ViewModels/View Models/AccountViewModel.swift b/ViewModels/Sources/ViewModels/View Models/AccountViewModel.swift index ba95545..936feea 100644 --- a/ViewModels/Sources/ViewModels/View Models/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/AccountViewModel.swift @@ -95,6 +95,10 @@ public extension AccountViewModel { ReportViewModel(accountService: accountService, identityContext: identityContext) } + func muteViewModel() -> MuteViewModel { + MuteViewModel(accountService: accountService, identityContext: identityContext) + } + func follow() { ignorableOutputEvent(accountService.follow()) } @@ -127,8 +131,12 @@ public extension AccountViewModel { ignorableOutputEvent(accountService.unblock()) } - func mute() { - ignorableOutputEvent(accountService.mute()) + func confirmMute() { + eventsSubject.send(Just(.confirmMute(self)).setFailureType(to: Error.self).eraseToAnyPublisher()) + } + + func confirmUnmute() { + eventsSubject.send(Just(.confirmUnmute(self)).setFailureType(to: Error.self).eraseToAnyPublisher()) } func unmute() { diff --git a/ViewModels/Sources/ViewModels/View Models/MuteViewModel.swift b/ViewModels/Sources/ViewModels/View Models/MuteViewModel.swift new file mode 100644 index 0000000..3d9a6fd --- /dev/null +++ b/ViewModels/Sources/ViewModels/View Models/MuteViewModel.swift @@ -0,0 +1,64 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import ServiceLayer + +public final class MuteViewModel: ObservableObject { + @Published public var notifications = true + @Published public var duration = Duration.indefinite + @Published public private(set) var loading = false + @Published public var alertItem: AlertItem? + public let events: AnyPublisher + public let identityContext: IdentityContext + + private let accountService: AccountService + private let eventsSubject = PassthroughSubject() + private var cancellables = Set() + + public init(accountService: AccountService, identityContext: IdentityContext) { + self.accountService = accountService + self.identityContext = identityContext + events = eventsSubject.eraseToAnyPublisher() + } +} + +public extension MuteViewModel { + enum Event { + case muted + } + + enum Duration: Int, CaseIterable { + case indefinite = 0 + case fiveMinutes = 300 + case thirtyMinutes = 1800 + case oneHour = 3600 + case sixHours = 21600 + case oneDay = 86400 + case threeDays = 259200 + case sevenDays = 604800 + } + + var accountName: String { "@".appending(accountService.account.acct) } + + func mute() { + accountService.mute(notifications: notifications, duration: duration.rawValue) + .receive(on: DispatchQueue.main) + .handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true }) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { [weak self] in + guard let self = self else { return } + + self.loading = false + + if $0 == .finished { + self.eventsSubject.send(.muted) + } + } receiveValue: { _ in } + .store(in: &cancellables) + } +} + +extension MuteViewModel.Duration: Identifiable { + public var id: Int { rawValue } +} diff --git a/Views/SwiftUI/MuteView.swift b/Views/SwiftUI/MuteView.swift new file mode 100644 index 0000000..69921d0 --- /dev/null +++ b/Views/SwiftUI/MuteView.swift @@ -0,0 +1,105 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct MuteView: View { + @StateObject var viewModel: MuteViewModel + + @Environment(\.presentationMode) private var presentationMode + fileprivate var dismissHostingController: (() -> Void)? + + var body: some View { + Form { + VStack(alignment: .leading, spacing: .defaultSpacing) { + Text("account.mute.confirm-\(viewModel.accountName)") + Text("account.mute.confirm.explanation") + } + Toggle("account.mute.confirm.hide-notifications", isOn: $viewModel.notifications) + Picker("account.mute.confirm.duration", selection: $viewModel.duration) { + ForEach(MuteViewModel.Duration.allCases) { duration in + Text(verbatim: duration.title).tag(duration) + } + } + Group { + if viewModel.loading { + ProgressView() + } else { + Button("account.mute") { + viewModel.mute() + } + } + } + } + .alertItem($viewModel.alertItem) + .onReceive(viewModel.events) { + switch $0 { + case .muted: + dismiss() + } + } + .navigationTitle("account.mute.target-\(viewModel.accountName)") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("cancel") { + dismiss() + } + } + } + .navigationBarTitleDisplayMode(.inline) + } +} + +private extension MuteView { + func dismiss() { + if let dismissHostingController = dismissHostingController { + dismissHostingController() + } else { + presentationMode.wrappedValue.dismiss() + } + } +} + +final class MuteViewController: UIHostingController { + init(viewModel: MuteViewModel) { + super.init(rootView: MuteView(viewModel: viewModel)) + + rootView.dismissHostingController = { [weak self] in self?.dismiss(animated: true) } + } + + @available(*, unavailable) + @objc required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension MuteViewModel.Duration { + static let dateComponentsFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + + formatter.unitsStyle = .full + + return formatter + }() + + var title: String { + switch self { + case .indefinite: + return NSLocalizedString("account.mute.indefinite", comment: "") + default: + return Self.dateComponentsFormatter.string(from: TimeInterval(rawValue)) ?? String(rawValue) + } + } +} + +#if DEBUG +import PreviewViewModels + +struct MuteView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + MuteView(viewModel: .preview) + } + } +} +#endif diff --git a/Views/SwiftUI/ReportView.swift b/Views/SwiftUI/ReportView.swift index 5aaef59..22b9216 100644 --- a/Views/SwiftUI/ReportView.swift +++ b/Views/SwiftUI/ReportView.swift @@ -52,10 +52,8 @@ struct ReportView: View { .navigationTitle("report.target-\(viewModel.accountName)") .toolbar { ToolbarItem(placement: .cancellationAction) { - Button { + Button("cancel") { dismiss() - } label: { - Image(systemName: "xmark.circle.fill") } } } @@ -75,7 +73,7 @@ private extension ReportView { } } -class ReportViewController: UIHostingController { +final class ReportViewController: UIHostingController { init(viewModel: ReportViewModel) { super.init(rootView: ReportView(viewModel: viewModel)) diff --git a/Views/UIKit/Content Views/StatusView.swift b/Views/UIKit/Content Views/StatusView.swift index 217073f..6c42fbd 100644 --- a/Views/UIKit/Content Views/StatusView.swift +++ b/Views/UIKit/Content Views/StatusView.swift @@ -681,13 +681,13 @@ private extension StatusView { secondSectionItems.append(UIAction( title: NSLocalizedString("account.unmute", comment: ""), image: UIImage(systemName: "speaker")) { _ in - viewModel.accountViewModel.unmute() + viewModel.accountViewModel.confirmUnmute() }) } else { secondSectionItems.append(UIAction( title: NSLocalizedString("account.mute", comment: ""), image: UIImage(systemName: "speaker.slash")) { _ in - viewModel.accountViewModel.mute() + viewModel.accountViewModel.confirmMute() }) }