diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index e4338e8..3af428a 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -27,7 +27,7 @@ extension ContentDatabase { t.column("emojis", .blob).notNull() t.column("bot", .boolean).notNull() t.column("discoverable", .boolean) - t.column("movedId", .text).references("accountRecord") + t.column("movedId", .text).references("accountRecord", onDelete: .cascade) } try db.create(table: "relationship") { t in @@ -61,7 +61,7 @@ extension ContentDatabase { t.column("id", .text).primaryKey(onConflict: .replace) t.column("uri", .text).notNull() t.column("createdAt", .datetime).notNull() - t.column("accountId", .text).notNull().references("accountRecord") + t.column("accountId", .text).notNull().references("accountRecord", onDelete: .cascade) t.column("content", .text).notNull() t.column("visibility", .text).notNull() t.column("sensitive", .boolean).notNull() @@ -77,7 +77,7 @@ extension ContentDatabase { t.column("url", .text) t.column("inReplyToId", .text) t.column("inReplyToAccountId", .text) - t.column("reblogId", .text).references("statusRecord") + t.column("reblogId", .text).references("statusRecord", onDelete: .cascade) t.column("poll", .blob) t.column("card", .blob) t.column("language", .text) @@ -135,7 +135,7 @@ extension ContentDatabase { try db.create(table: "conversationRecord") { t in t.column("id", .text).primaryKey(onConflict: .replace) t.column("unread", .boolean).notNull() - t.column("lastStatusId", .text).references("statusRecord") + t.column("lastStatusId", .text).references("statusRecord", onDelete: .cascade) } try db.create(table: "conversationAccountJoin") { t in @@ -155,8 +155,8 @@ extension ContentDatabase { try db.create(table: "notificationRecord") { t in t.column("id", .text).primaryKey(onConflict: .replace) t.column("type", .text).notNull() - t.column("accountId", .text).notNull().references("accountRecord") - t.column("statusId").references("statusRecord") + t.column("accountId", .text).notNull().references("accountRecord", onDelete: .cascade) + t.column("statusId").references("statusRecord", onDelete: .cascade) } try db.create(table: "statusAncestorJoin") { t in diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 5996e1f..3b22e10 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -233,6 +233,21 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func mute(id: Account.Id) -> AnyPublisher { + databaseWriter.writePublisher { + try StatusRecord.filter(StatusRecord.Columns.accountId == id).deleteAll($0) + try NotificationRecord.filter(NotificationRecord.Columns.accountId == id).deleteAll($0) + } + .ignoreOutput() + .eraseToAnyPublisher() + } + + func block(id: Account.Id) -> AnyPublisher { + databaseWriter.writePublisher(updates: AccountRecord.filter(AccountRecord.Columns.id == id).deleteAll) + .ignoreOutput() + .eraseToAnyPublisher() + } + func append(accounts: [Account], toList list: AccountList) -> AnyPublisher { databaseWriter.writePublisher { try list.save($0) diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index e3ac5a4..f5d1aa3 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -1,13 +1,19 @@ // Copyright © 2020 Metabolist. All rights reserved. +"account.block-account" = "Block %@"; "account.field.verified" = "Verified %@"; "account.follow" = "Follow"; "account.following" = "Following"; +"account.hide-reblogs-account" = "Hide reblogs from %@"; +"account.mute-account" = "Mute %@"; "account.request" = "Request"; "account.statuses" = "Posts"; "account.statuses-and-replies" = "Posts & Replies"; "account.media" = "Media"; +"account.show-reblogs-account" = "Show reblogs from %@"; +"account.unblock-account" = "Unblock %@"; "account.unfollow-account" = "Unfollow %@"; +"account.unmute-account" = "Unmute %@"; "add" = "Add"; "apns-default-message" = "New notification"; "add-identity.instance-url" = "Instance URL"; diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift index 5467c12..5fae164 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift @@ -5,8 +5,15 @@ import HTTP import Mastodon public enum RelationshipEndpoint { - case accountsFollow(id: Account.Id) + case accountsFollow(id: Account.Id, showReblogs: Bool? = nil) case accountsUnfollow(id: Account.Id) + case accountsBlock(id: Account.Id) + case accountsUnblock(id: Account.Id) + case accountsMute(id: Account.Id) + case accountsUnmute(id: Account.Id) + case accountsPin(id: Account.Id) + case accountsUnpin(id: Account.Id) + case note(String, id: Account.Id) } extension RelationshipEndpoint: Endpoint { @@ -18,17 +25,50 @@ extension RelationshipEndpoint: Endpoint { public var pathComponentsInContext: [String] { switch self { - case let .accountsFollow(id): + case let .accountsFollow(id, _): return [id, "follow"] case let .accountsUnfollow(id): return [id, "unfollow"] + case let .accountsBlock(id): + return [id, "block"] + case let .accountsUnblock(id): + return [id, "unblock"] + case let .accountsMute(id): + return [id, "mute"] + case let .accountsUnmute(id): + return [id, "unmute"] + case let .accountsPin(id): + return [id, "pin"] + case let .accountsUnpin(id): + return [id, "unpin"] + case let .note(_, id): + return [id, "note"] + } + } + + public var queryParameters: [URLQueryItem] { + switch self { + case let .accountsFollow(_, showReblogs): + if let showReblogs = showReblogs { + return [URLQueryItem(name: "reblogs", value: String(showReblogs))] + } else { + return [] + } + default: + return [] + } + } + + public var jsonBody: [String: Any]? { + switch self { + case let .note(note, _): + return ["comment": note] + default: + return nil } } public var method: HTTPMethod { - switch self { - case .accountsFollow, .accountsUnfollow: - return .post - } + .post } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift index d0f70f7..04243c3 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift @@ -31,17 +31,63 @@ public struct AccountService { public extension AccountService { func follow() -> AnyPublisher { - mastodonAPIClient.request(RelationshipEndpoint.accountsFollow(id: account.id)) - .flatMap { contentDatabase.insert(relationships: [$0]) } - .eraseToAnyPublisher() + relationshipAction(.accountsFollow(id: account.id)) } func unfollow() -> AnyPublisher { - mastodonAPIClient.request(RelationshipEndpoint.accountsUnfollow(id: account.id)) - .flatMap { - contentDatabase.insert(relationships: [$0]) - .merge(with: contentDatabase.unfollow(id: account.id)) - } + relationshipAction(.accountsUnfollow(id: account.id)) + .collect() + .flatMap { _ in contentDatabase.unfollow(id: account.id) } + .eraseToAnyPublisher() + } + + func hideReblogs() -> AnyPublisher { + relationshipAction(.accountsFollow(id: account.id, showReblogs: false)) + } + + func showReblogs() -> AnyPublisher { + relationshipAction(.accountsFollow(id: account.id, showReblogs: true)) + } + + func block() -> AnyPublisher { + relationshipAction(.accountsBlock(id: account.id)) + .collect() + .flatMap { _ in contentDatabase.block(id: account.id) } + .eraseToAnyPublisher() + } + + func unblock() -> AnyPublisher { + relationshipAction(.accountsUnblock(id: account.id)) + } + + func mute() -> AnyPublisher { + relationshipAction(.accountsMute(id: account.id)) + .collect() + .flatMap { _ in contentDatabase.mute(id: account.id) } + .eraseToAnyPublisher() + } + + func unmute() -> AnyPublisher { + relationshipAction(.accountsUnmute(id: account.id)) + } + + func pin() -> AnyPublisher { + relationshipAction(.accountsPin(id: account.id)) + } + + func unpin() -> AnyPublisher { + relationshipAction(.accountsUnpin(id: account.id)) + } + + func set(note: String) -> AnyPublisher { + relationshipAction(.note(note, id: account.id)) + } +} + +private extension AccountService { + func relationshipAction(_ endpoint: RelationshipEndpoint) -> AnyPublisher { + mastodonAPIClient.request(endpoint) + .flatMap { contentDatabase.insert(relationships: [$0]) } .eraseToAnyPublisher() } } diff --git a/View Controllers/ProfileViewController.swift b/View Controllers/ProfileViewController.swift index a1f03a7..8097983 100644 --- a/View Controllers/ProfileViewController.swift +++ b/View Controllers/ProfileViewController.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import Combine +import Mastodon import UIKit import ViewModels @@ -24,9 +25,18 @@ final class ProfileViewController: TableViewController { viewModel.$accountViewModel .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - accountHeaderView.viewModel = self?.viewModel - self?.sizeTableHeaderFooterViews() + .sink { [weak self] in + guard let self = self else { return } + + accountHeaderView.viewModel = self.viewModel + self.sizeTableHeaderFooterViews() + + if let accountViewModel = $0, + let relationship = accountViewModel.relationship { + self.navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "ellipsis.circle"), + menu: self.menu(accountViewModel: accountViewModel, relationship: relationship)) + } } .store(in: &cancellables) @@ -54,3 +64,68 @@ final class ProfileViewController: TableViewController { .store(in: &cancellables) } } + +private extension ProfileViewController { + // swiftlint:disable:next function_body_length + func menu(accountViewModel: AccountViewModel, relationship: Relationship) -> UIMenu { + var actions = [UIAction]() + + if relationship.following { + if relationship.showingReblogs { + actions.append(UIAction( + title: String.localizedStringWithFormat( + NSLocalizedString("account.hide-reblogs-account", comment: ""), + accountViewModel.accountName), + image: UIImage(systemName: "arrow.2.squarepath")) { _ in + accountViewModel.hideReblogs() + }) + } else { + actions.append(UIAction( + title: String.localizedStringWithFormat( + NSLocalizedString("account.show-reblogs-account", comment: ""), + accountViewModel.accountName), + image: UIImage(systemName: "arrow.2.squarepath")) { _ in + accountViewModel.showReblogs() + }) + } + } + + if relationship.muting { + actions.append(UIAction( + title: String.localizedStringWithFormat( + NSLocalizedString("account.unmute-account", comment: ""), + accountViewModel.accountName), + image: UIImage(systemName: "speaker")) { _ in + accountViewModel.unmute() + }) + } else { + actions.append(UIAction( + title: String.localizedStringWithFormat( + NSLocalizedString("account.mute-account", comment: ""), + accountViewModel.accountName), + image: UIImage(systemName: "speaker.slash")) { _ in + accountViewModel.mute() + }) + } + + if relationship.blocking { + actions.append(UIAction( + title: String.localizedStringWithFormat( + NSLocalizedString("account.unblock-account", comment: ""), + accountViewModel.accountName), + image: UIImage(systemName: "slash.circle")) { _ in + accountViewModel.unblock() + }) + } else { + actions.append(UIAction( + title: String.localizedStringWithFormat( + NSLocalizedString("account.block-account", comment: ""), + accountViewModel.accountName), + image: UIImage(systemName: "slash.circle")) { _ in + accountViewModel.block() + }) + } + + return UIMenu(children: actions) + } +} diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift index 593c2cd..9dafa7f 100644 --- a/ViewModels/Sources/ViewModels/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -67,10 +67,52 @@ public extension AccountViewModel { } func follow() { - eventsSubject.send(accountService.follow().map { _ in .ignorableOutput }.eraseToAnyPublisher()) + ignorableOutputEvent(accountService.follow()) } func unfollow() { - eventsSubject.send(accountService.unfollow().map { _ in .ignorableOutput }.eraseToAnyPublisher()) + ignorableOutputEvent(accountService.unfollow()) + } + + func hideReblogs() { + ignorableOutputEvent(accountService.hideReblogs()) + } + + func showReblogs() { + ignorableOutputEvent(accountService.showReblogs()) + } + + func block() { + ignorableOutputEvent(accountService.block()) + } + + func unblock() { + ignorableOutputEvent(accountService.unblock()) + } + + func mute() { + ignorableOutputEvent(accountService.mute()) + } + + func unmute() { + ignorableOutputEvent(accountService.unmute()) + } + + func pin() { + ignorableOutputEvent(accountService.pin()) + } + + func unpin() { + ignorableOutputEvent(accountService.unpin()) + } + + func set(note: String) { + ignorableOutputEvent(accountService.set(note: note)) + } +} + +private extension AccountViewModel { + func ignorableOutputEvent(_ action: AnyPublisher) { + eventsSubject.send(action.map { _ in .ignorableOutput }.eraseToAnyPublisher()) } } diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index 4081c78..9f32bb9 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -71,7 +71,7 @@ extension ProfileViewModel: CollectionViewModel { } public var expandAll: AnyPublisher { - collectionViewModel.flatMap(\.expandAll).eraseToAnyPublisher() + Empty().eraseToAnyPublisher() } public var alertItems: AnyPublisher {