From fa4d666f8d73295da7d3c431dcca1738e2e6f71e Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 7 Oct 2020 14:06:26 -0700 Subject: [PATCH] Content warnings --- .../Content/ContentDatabase+Migration.swift | 4 ++ DB/Sources/DB/Content/ContentDatabase.swift | 33 +++++++++ DB/Sources/DB/Content/ContextItemsInfo.swift | 3 +- .../DB/Content/StatusAncestorJoin.swift | 4 +- .../DB/Content/StatusDescendantJoin.swift | 4 +- DB/Sources/DB/Content/StatusInfo.swift | 9 +++ DB/Sources/DB/Content/StatusRecord.swift | 5 ++ .../DB/Content/StatusShowMoreToggle.swift | 25 +++++++ DB/Sources/DB/Content/TimelineItemsInfo.swift | 12 +++- DB/Sources/DB/Entities/CollectionItem.swift | 7 +- Data Sources/TableViewDataSource.swift | 49 +++++++++++++ Metatext.xcodeproj/project.pbxproj | 12 ++++ .../Services/AccountListService.swift | 4 ++ .../Services/CollectionService.swift | 2 + .../Services/ContextService.swift | 12 ++++ .../ServiceLayer/Services/StatusService.swift | 4 ++ .../Services/TimelineService.swift | 4 ++ View Controllers/TableViewController.swift | 72 +++++++++---------- .../ViewModels/CollectionItemsViewModel.swift | 50 +++++++++++-- .../ViewModels/CollectionViewModel.swift | 5 +- .../Entities/CollectionItemIdentifier.swift | 20 +++--- .../Entities/CollectionUpdate.swift | 6 ++ .../Entities/ShowMoreForAllState.swift | 7 ++ .../Sources/ViewModels/ProfileViewModel.swift | 16 +++-- .../Sources/ViewModels/StatusViewModel.swift | 22 ++++-- Views/Status/StatusView.swift | 18 +++-- Views/Status/StatusView.xib | 4 +- 27 files changed, 329 insertions(+), 84 deletions(-) create mode 100644 DB/Sources/DB/Content/StatusShowMoreToggle.swift create mode 100644 Data Sources/TableViewDataSource.swift create mode 100644 ViewModels/Sources/ViewModels/Entities/CollectionUpdate.swift create mode 100644 ViewModels/Sources/ViewModels/Entities/ShowMoreForAllState.swift diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index 4af20b3..8a1ed72 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -62,6 +62,10 @@ extension ContentDatabase { t.column("pinned", .boolean) } + try db.create(table: "statusShowMoreToggle") { t in + t.column("statusId", .text).primaryKey().references("statusRecord", onDelete: .cascade) + } + try db.create(table: "timelineRecord") { t in t.column("id", .text).primaryKey(onConflict: .replace) t.column("listId", .text) diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 0bc4495..05a825b 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -149,6 +149,39 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func toggleShowMore(id: Status.Id) -> AnyPublisher { + databaseWriter.writePublisher { + if let toggle = try StatusShowMoreToggle + .filter(StatusShowMoreToggle.Columns.statusId == id) + .fetchOne($0) { + try toggle.delete($0) + } else { + try StatusShowMoreToggle(statusId: id).save($0) + } + } + .ignoreOutput() + .eraseToAnyPublisher() + } + + func showMore(ids: Set) -> AnyPublisher { + databaseWriter.writePublisher { + for id in ids { + try StatusShowMoreToggle(statusId: id).save($0) + } + } + .ignoreOutput() + .eraseToAnyPublisher() + } + + func showLess(ids: Set) -> AnyPublisher { + databaseWriter.writePublisher( + updates: StatusShowMoreToggle + .filter(ids.contains(StatusShowMoreToggle.Columns.statusId)) + .deleteAll) + .ignoreOutput() + .eraseToAnyPublisher() + } + func append(accounts: [Account], toList list: AccountList) -> AnyPublisher { databaseWriter.writePublisher { try list.save($0) diff --git a/DB/Sources/DB/Content/ContextItemsInfo.swift b/DB/Sources/DB/Content/ContextItemsInfo.swift index 96d55bc..fe0d9f2 100644 --- a/DB/Sources/DB/Content/ContextItemsInfo.swift +++ b/DB/Sources/DB/Content/ContextItemsInfo.swift @@ -35,7 +35,8 @@ extension ContextItemsInfo { return .status( .init(info: statusInfo), - .init(isContextParent: statusInfo.record.id == parent.record.id, + .init(showMoreToggled: statusInfo.showMoreToggled, + isContextParent: statusInfo.record.id == parent.record.id, isReplyInContext: isReplyInContext, hasReplyFollowing: hasReplyFollowing)) } diff --git a/DB/Sources/DB/Content/StatusAncestorJoin.swift b/DB/Sources/DB/Content/StatusAncestorJoin.swift index 45340b1..a697a13 100644 --- a/DB/Sources/DB/Content/StatusAncestorJoin.swift +++ b/DB/Sources/DB/Content/StatusAncestorJoin.swift @@ -8,8 +8,6 @@ struct StatusAncestorJoin: Codable, FetchableRecord, PersistableRecord { let parentId: Status.Id let statusId: Status.Id let index: Int - - static let status = belongsTo(StatusRecord.self, using: ForeignKey([Columns.statusId])) } extension StatusAncestorJoin { @@ -18,4 +16,6 @@ extension StatusAncestorJoin { static let statusId = Column(StatusAncestorJoin.CodingKeys.statusId) static let index = Column(StatusAncestorJoin.CodingKeys.index) } + + static let status = belongsTo(StatusRecord.self, using: ForeignKey([Columns.statusId])) } diff --git a/DB/Sources/DB/Content/StatusDescendantJoin.swift b/DB/Sources/DB/Content/StatusDescendantJoin.swift index 31e3904..ece72d0 100644 --- a/DB/Sources/DB/Content/StatusDescendantJoin.swift +++ b/DB/Sources/DB/Content/StatusDescendantJoin.swift @@ -8,8 +8,6 @@ struct StatusDescendantJoin: Codable, FetchableRecord, PersistableRecord { let parentId: Status.Id let statusId: Status.Id let index: Int - - static let status = belongsTo(StatusRecord.self, using: ForeignKey([Columns.statusId])) } extension StatusDescendantJoin { @@ -18,4 +16,6 @@ extension StatusDescendantJoin { static let statusId = Column(StatusDescendantJoin.CodingKeys.statusId) static let index = Column(StatusDescendantJoin.CodingKeys.index) } + + static let status = belongsTo(StatusRecord.self, using: ForeignKey([Columns.statusId])) } diff --git a/DB/Sources/DB/Content/StatusInfo.swift b/DB/Sources/DB/Content/StatusInfo.swift index 84e64d5..651f506 100644 --- a/DB/Sources/DB/Content/StatusInfo.swift +++ b/DB/Sources/DB/Content/StatusInfo.swift @@ -8,6 +8,8 @@ struct StatusInfo: Codable, Hashable, FetchableRecord { let accountInfo: AccountInfo let reblogAccountInfo: AccountInfo? let reblogRecord: StatusRecord? + let showMoreToggle: StatusShowMoreToggle? + let reblogShowMoreToggle: StatusShowMoreToggle? } extension StatusInfo { @@ -16,6 +18,9 @@ extension StatusInfo { .including(optional: AccountInfo.addingIncludes(StatusRecord.reblogAccount) .forKey(CodingKeys.reblogAccountInfo)) .including(optional: StatusRecord.reblog.forKey(CodingKeys.reblogRecord)) + .including(optional: StatusRecord.showMoreToggle.forKey(CodingKeys.showMoreToggle)) + .including(optional: StatusRecord.reblogShowMoreToggle + .forKey(CodingKeys.reblogShowMoreToggle)) } static func request(_ request: QueryInterfaceRequest) -> QueryInterfaceRequest { @@ -25,4 +30,8 @@ extension StatusInfo { var filterableContent: String { (record.filterableContent + (reblogRecord?.filterableContent ?? [])).joined(separator: " ") } + + var showMoreToggled: Bool { + showMoreToggle != nil || reblogShowMoreToggle != nil + } } diff --git a/DB/Sources/DB/Content/StatusRecord.swift b/DB/Sources/DB/Content/StatusRecord.swift index afeabdd..97d023f 100644 --- a/DB/Sources/DB/Content/StatusRecord.swift +++ b/DB/Sources/DB/Content/StatusRecord.swift @@ -92,6 +92,11 @@ extension StatusRecord { through: Self.reblogAccount, using: AccountRecord.moved) static let reblog = belongsTo(StatusRecord.self) + static let showMoreToggle = hasOne(StatusShowMoreToggle.self) + static let reblogShowMoreToggle = hasOne( + StatusShowMoreToggle.self, + through: Self.reblog, + using: Self.showMoreToggle) static let ancestorJoins = hasMany( StatusAncestorJoin.self, using: ForeignKey([StatusAncestorJoin.Columns.parentId])) diff --git a/DB/Sources/DB/Content/StatusShowMoreToggle.swift b/DB/Sources/DB/Content/StatusShowMoreToggle.swift new file mode 100644 index 0000000..2db6b18 --- /dev/null +++ b/DB/Sources/DB/Content/StatusShowMoreToggle.swift @@ -0,0 +1,25 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct StatusShowMoreToggle: Codable, Hashable { + let statusId: Status.Id +} + +extension StatusShowMoreToggle { + enum Columns { + static let statusId = Column(StatusShowMoreToggle.CodingKeys.statusId) + } +} + +extension StatusShowMoreToggle: FetchableRecord, PersistableRecord { + static func databaseJSONDecoder(for column: String) -> JSONDecoder { + MastodonDecoder() + } + + static func databaseJSONEncoder(for column: String) -> JSONEncoder { + MastodonEncoder() + } +} diff --git a/DB/Sources/DB/Content/TimelineItemsInfo.swift b/DB/Sources/DB/Content/TimelineItemsInfo.swift index c9dafda..bcbbc81 100644 --- a/DB/Sources/DB/Content/TimelineItemsInfo.swift +++ b/DB/Sources/DB/Content/TimelineItemsInfo.swift @@ -32,7 +32,11 @@ extension TimelineItemsInfo { let timeline = Timeline(record: timelineRecord)! let filterRegularExpression = filters.regularExpression(context: timeline.filterContext) var timelineItems = statusInfos.filtered(regularExpression: filterRegularExpression) - .map { CollectionItem.status(.init(info: $0), .init()) } + .map { + CollectionItem.status( + .init(info: $0), + .init(showMoreToggled: $0.showMoreToggled)) + } for loadMoreRecord in loadMoreRecords { guard let index = timelineItems.firstIndex(where: { @@ -51,7 +55,11 @@ extension TimelineItemsInfo { if let pinnedStatusInfos = pinnedStatusesInfo?.pinnedStatusInfos { return [pinnedStatusInfos.filtered(regularExpression: filterRegularExpression) - .map { CollectionItem.status(.init(info: $0), .init(isPinned: true)) }, + .map { + CollectionItem.status( + .init(info: $0), + .init(showMoreToggled: $0.showMoreToggled, isPinned: true)) + }, timelineItems] } else { return [timelineItems] diff --git a/DB/Sources/DB/Entities/CollectionItem.swift b/DB/Sources/DB/Entities/CollectionItem.swift index 59219f7..e00c840 100644 --- a/DB/Sources/DB/Entities/CollectionItem.swift +++ b/DB/Sources/DB/Entities/CollectionItem.swift @@ -10,15 +10,18 @@ public enum CollectionItem: Hashable { public extension CollectionItem { struct StatusConfiguration: Hashable { + public let showMoreToggled: Bool public let isContextParent: Bool public let isPinned: Bool public let isReplyInContext: Bool public let hasReplyFollowing: Bool - init(isContextParent: Bool = false, + init(showMoreToggled: Bool, + isContextParent: Bool = false, isPinned: Bool = false, isReplyInContext: Bool = false, hasReplyFollowing: Bool = false) { + self.showMoreToggled = showMoreToggled self.isContextParent = isContextParent self.isPinned = isPinned self.isReplyInContext = isReplyInContext @@ -28,5 +31,5 @@ public extension CollectionItem { } public extension CollectionItem.StatusConfiguration { - static let `default` = Self() + static let `default` = Self(showMoreToggled: false) } diff --git a/Data Sources/TableViewDataSource.swift b/Data Sources/TableViewDataSource.swift new file mode 100644 index 0000000..f5fcfd1 --- /dev/null +++ b/Data Sources/TableViewDataSource.swift @@ -0,0 +1,49 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +class TableViewDataSource: UITableViewDiffableDataSource { + private let updateQueue = + DispatchQueue(label: "com.metabolist.metatext.collection-data-source.update-queue") + + init(tableView: UITableView, viewModelProvider: @escaping (IndexPath) -> CollectionItemViewModel) { + for kind in CollectionItemIdentifier.Kind.allCases { + tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass)) + } + + super.init(tableView: tableView) { tableView, indexPath, identifier in + let cell = tableView.dequeueReusableCell( + withIdentifier: String(describing: identifier.kind.cellClass), + for: indexPath) + + switch (cell, viewModelProvider(indexPath)) { + case let (statusListCell as StatusListCell, statusViewModel as StatusViewModel): + statusListCell.viewModel = statusViewModel + case let (accountListCell as AccountListCell, accountViewModel as AccountViewModel): + accountListCell.viewModel = accountViewModel + case let (loadMoreCell as LoadMoreCell, loadMoreViewModel as LoadMoreViewModel): + loadMoreCell.viewModel = loadMoreViewModel + default: + break + } + + return cell + } + + defaultRowAnimation = .none + } + + override func apply(_ snapshot: NSDiffableDataSourceSnapshot, + animatingDifferences: Bool = true, + completion: (() -> Void)? = nil) { + let differenceExceptShowMoreToggled = self.snapshot().itemIdentifiers.difference( + from: snapshot.itemIdentifiers, + by: CollectionItemIdentifier.isSameExceptShowMoreToggled(lhs:rhs:)) + let animated = snapshot.itemIdentifiers.count > 0 && differenceExceptShowMoreToggled.count == 0 + + updateQueue.async { + super.apply(snapshot, animatingDifferences: animated, completion: completion) + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 76673f2..5ed9757 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ D0625E5F250F0CFF00502611 /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5E250F0CFF00502611 /* StatusView.swift */; }; D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; }; D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; }; + D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */; }; D0B7434925100DBB00C13DB6 /* StatusView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D0B7434825100DBB00C13DB6 /* StatusView.xib */; }; @@ -111,6 +112,7 @@ D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = ""; }; + D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = ""; }; @@ -212,6 +214,7 @@ D0C7D45224F76169001EBDBB /* Assets.xcassets */, D0AD03552505814D0085A466 /* Base16 */, D0D7C013250440610039AD6F /* CodableBloomFilter */, + D0A1F4F5252E7D2A004435BF /* Data Sources */, D085C3BB25008DEC008A6C5E /* DB */, D0C7D46824F76169001EBDBB /* Extensions */, D0666A7924C7745A00F3F04B /* Frameworks */, @@ -270,6 +273,14 @@ name = Frameworks; sourceTree = ""; }; + D0A1F4F5252E7D2A004435BF /* Data Sources */ = { + isa = PBXGroup; + children = ( + D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */, + ); + path = "Data Sources"; + sourceTree = ""; + }; D0C7D41D24F76169001EBDBB /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -573,6 +584,7 @@ D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */, D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */, + D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */, D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */, D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift index 56f6471..1c7415a 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift @@ -41,4 +41,8 @@ extension AccountListService: CollectionService { .flatMap { contentDatabase.append(accounts: $0.result, toList: list) } .eraseToAnyPublisher() } + + public func toggleShowMore(id: Status.Id) -> AnyPublisher { + contentDatabase.toggleShowMore(id: id) + } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift index 6982187..76eb92a 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import Combine +import Mastodon public protocol CollectionService { var sections: AnyPublisher<[[CollectionItem]], Error> { get } @@ -8,6 +9,7 @@ public protocol CollectionService { var title: AnyPublisher { get } var navigationService: NavigationService { get } func request(maxId: String?, minId: String?) -> AnyPublisher + func toggleShowMore(id: Status.Id) -> AnyPublisher } extension CollectionService { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/ContextService.swift b/ServiceLayer/Sources/ServiceLayer/Services/ContextService.swift index e568882..24ec9dc 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/ContextService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/ContextService.swift @@ -31,4 +31,16 @@ extension ContextService: CollectionService { .flatMap { contentDatabase.insert(context: $0, parentId: id) }) .eraseToAnyPublisher() } + + public func toggleShowMore(id: Status.Id) -> AnyPublisher { + contentDatabase.toggleShowMore(id: id) + } + + public func showMore(ids: Set) -> AnyPublisher { + contentDatabase.showMore(ids: ids) + } + + public func showLess(ids: Set) -> AnyPublisher { + contentDatabase.showLess(ids: ids) + } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift b/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift index d4c460c..872d3a9 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/StatusService.swift @@ -24,6 +24,10 @@ public struct StatusService { } public extension StatusService { + func toggleShowMore() -> AnyPublisher { + contentDatabase.toggleShowMore(id: status.displayStatus.id) + } + func toggleFavorited() -> AnyPublisher { mastodonAPIClient.request(status.displayStatus.favourited ? StatusEndpoint.unfavourite(id: status.displayStatus.id) diff --git a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift index e1cc3bb..ca97727 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift @@ -44,4 +44,8 @@ extension TimelineService: CollectionService { .flatMap { contentDatabase.insert(statuses: $0.result, timeline: timeline) } .eraseToAnyPublisher() } + + public func toggleShowMore(id: Status.Id) -> AnyPublisher { + contentDatabase.toggleShowMore(id: id) + } } diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 663ddb3..1f6ffae 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -12,30 +12,9 @@ class TableViewController: UITableViewController { private let webfingerIndicatorView = WebfingerIndicatorView() private var cancellables = Set() private var cellHeightCaches = [CGFloat: [CollectionItemIdentifier: CGFloat]]() - private let dataSourceQueue = - DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue") - private lazy var dataSource: UITableViewDiffableDataSource = { - UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, identifier in - guard let cellViewModel = self?.viewModel.viewModel(indexPath: indexPath) else { return nil } - - let cell = tableView.dequeueReusableCell( - withIdentifier: String(describing: identifier.kind.cellClass), - for: indexPath) - - switch (cell, cellViewModel) { - case (let statusListCell as StatusListCell, let statusViewModel as StatusViewModel): - statusListCell.viewModel = statusViewModel - case (let accountListCell as AccountListCell, let accountViewModel as AccountViewModel): - accountListCell.viewModel = accountViewModel - case (let loadMoreCell as LoadMoreCell, let loadMoreViewModel as LoadMoreViewModel): - loadMoreCell.viewModel = loadMoreViewModel - default: - return nil - } - - return cell - } + private lazy var dataSource: TableViewDataSource = { + .init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:)) }() init(viewModel: CollectionViewModel, identification: Identification) { @@ -53,10 +32,6 @@ class TableViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - for kind in CollectionItemIdentifier.Kind.allCases { - tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass)) - } - tableView.dataSource = dataSource tableView.prefetchDataSource = self tableView.cellLayoutMarginsFollowReadableWidth = true @@ -183,12 +158,16 @@ private extension TableViewController { func setupViewModelBindings() { viewModel.title.sink { [weak self] in self?.navigationItem.title = $0 }.store(in: &cancellables) - viewModel.sections.sink { [weak self] in self?.update(items: $0) }.store(in: &cancellables) + viewModel.updates.sink { [weak self] in self?.update($0) }.store(in: &cancellables) viewModel.events.receive(on: DispatchQueue.main) .sink { [weak self] in self?.handle(event: $0) } .store(in: &cancellables) + viewModel.showMoreForAll.receive(on: DispatchQueue.main) + .sink { [weak self] in self?.set(showMoreForAllState: $0) } + .store(in: &cancellables) + viewModel.loading.receive(on: RunLoop.main).sink { [weak self] in guard let self = self else { return } @@ -204,29 +183,27 @@ private extension TableViewController { .store(in: &cancellables) } - func update(items: [[CollectionItemIdentifier]]) { + func update(_ update: CollectionUpdate) { var offsetFromNavigationBar: CGFloat? if - let item = viewModel.maintainScrollPositionOfItem, + let item = update.maintainScrollPosition, let indexPath = dataSource.indexPath(for: item), let navigationBar = navigationController?.navigationBar { let navigationBarMaxY = tableView.convert(navigationBar.bounds, from: navigationBar).maxY offsetFromNavigationBar = tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY } - dataSourceQueue.async { [weak self] in + self.dataSource.apply(update.items.snapshot()) { [weak self] in guard let self = self else { return } - self.dataSource.apply(items.snapshot(), animatingDifferences: false) { - if - let item = self.viewModel.maintainScrollPositionOfItem, - let indexPath = self.dataSource.indexPath(for: item) { - self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) + if + let item = update.maintainScrollPosition, + let indexPath = self.dataSource.indexPath(for: item) { + self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) - if let offsetFromNavigationBar = offsetFromNavigationBar { - self.tableView.contentOffset.y -= offsetFromNavigationBar - } + if let offsetFromNavigationBar = offsetFromNavigationBar { + self.tableView.contentOffset.y -= offsetFromNavigationBar } } } @@ -264,6 +241,23 @@ private extension TableViewController { } } + func set(showMoreForAllState: ShowMoreForAllState) { + switch showMoreForAllState { + case .hidden: + navigationItem.rightBarButtonItem = nil + case .showMore: + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: NSLocalizedString("status.show-more", comment: ""), + image: UIImage(systemName: "eye.slash"), + primaryAction: UIAction { [weak self] _ in self?.viewModel.toggleShowMoreForAll() }) + case .showLess: + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: NSLocalizedString("status.show-less", comment: ""), + image: UIImage(systemName: "eye"), + primaryAction: UIAction { [weak self] _ in self?.viewModel.toggleShowMoreForAll() }) + } + } + func share(url: URL) { let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index 92f411e..bed5e98 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -8,7 +8,6 @@ import ServiceLayer final public class CollectionItemsViewModel: ObservableObject { @Published public var alertItem: AlertItem? public private(set) var nextPageMaxId: String? - public private(set) var maintainScrollPositionOfItem: CollectionItemIdentifier? private let items = CurrentValueSubject<[[CollectionItem]], Never>([]) private let collectionService: CollectionService @@ -16,6 +15,8 @@ final public class CollectionItemsViewModel: ObservableObject { private var viewModelCache = [CollectionItem: (viewModel: CollectionItemViewModel, events: AnyCancellable)]() private let eventsSubject = PassthroughSubject() private let loadingSubject = PassthroughSubject() + private let showMoreForAllSubject: CurrentValueSubject + private var maintainScrollPosition: CollectionItemIdentifier? private var topVisibleIndexPath = IndexPath(item: 0, section: 0) private var lastSelectedLoadMore: LoadMore? private var cancellables = Set() @@ -23,6 +24,9 @@ final public class CollectionItemsViewModel: ObservableObject { public init(collectionService: CollectionService, identification: Identification) { self.collectionService = collectionService self.identification = identification + showMoreForAllSubject = CurrentValueSubject( + collectionService is ContextService && !identification.identity.preferences.readingExpandSpoilers + ? .showMore : .hidden) collectionService.sections .handleEvents(receiveOutput: { [weak self] in self?.process(items: $0) }) @@ -38,12 +42,20 @@ final public class CollectionItemsViewModel: ObservableObject { } extension CollectionItemsViewModel: CollectionViewModel { - public var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> { - items.map { $0.map { $0.map(CollectionItemIdentifier.init(item:)) } }.eraseToAnyPublisher() + public var updates: AnyPublisher { + items.map { [weak self] in + CollectionUpdate(items: $0.map { $0.map(CollectionItemIdentifier.init(item:)) }, + maintainScrollPosition: self?.maintainScrollPosition) + } + .eraseToAnyPublisher() } public var title: AnyPublisher { collectionService.title } + public var showMoreForAll: AnyPublisher { + showMoreForAllSubject.eraseToAnyPublisher() + } + public var alertItems: AnyPublisher { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } public var loading: AnyPublisher { loadingSubject.eraseToAnyPublisher() } @@ -107,7 +119,9 @@ extension CollectionItemsViewModel: CollectionViewModel { if let cachedViewModel = cachedViewModel as? StatusViewModel { viewModel = cachedViewModel } else { - viewModel = .init(statusService: collectionService.navigationService.statusService(status: status)) + viewModel = .init( + statusService: collectionService.navigationService.statusService(status: status), + identification: identification) cache(viewModel: viewModel, forItem: item) } @@ -138,6 +152,31 @@ extension CollectionItemsViewModel: CollectionViewModel { return viewModel } } + + public func toggleShowMoreForAll() { + let statusIds = Set(items.value.reduce([], +).compactMap { item -> Status.Id? in + guard case let .status(status, _) = item else { return nil } + + return status.id + }) + + switch showMoreForAllSubject.value { + case .hidden: + break + case .showMore: + (collectionService as? ContextService)?.showMore(ids: statusIds) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .collect() + .sink { [weak self] _ in self?.showMoreForAllSubject.send(.showLess) } + .store(in: &cancellables) + case .showLess: + (collectionService as? ContextService)?.showLess(ids: statusIds) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .collect() + .sink { [weak self] _ in self?.showMoreForAllSubject.send(.showMore) } + .store(in: &cancellables) + } + } } private extension CollectionItemsViewModel { @@ -148,7 +187,7 @@ private extension CollectionItemsViewModel { } func process(items: [[CollectionItem]]) { - maintainScrollPositionOfItem = identifierForScrollPositionMaintenance(newItems: items) + maintainScrollPosition = identifierForScrollPositionMaintenance(newItems: items) self.items.send(items) let itemsSet = Set(items.reduce([], +)) @@ -158,6 +197,7 @@ private extension CollectionItemsViewModel { func identifierForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItemIdentifier? { let flatNewItems = newItems.reduce([], +) + if collectionService is ContextService, items.value.isEmpty || items.value.map(\.count) == [0, 1, 0], let contextParent = flatNewItems.first(where: { diff --git a/ViewModels/Sources/ViewModels/CollectionViewModel.swift b/ViewModels/Sources/ViewModels/CollectionViewModel.swift index 884daa9..4e793e4 100644 --- a/ViewModels/Sources/ViewModels/CollectionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionViewModel.swift @@ -4,16 +4,17 @@ import Combine import Foundation public protocol CollectionViewModel { - var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> { get } + var updates: AnyPublisher { get } var title: AnyPublisher { get } + var showMoreForAll: AnyPublisher { get } var alertItems: AnyPublisher { get } var loading: AnyPublisher { get } var events: AnyPublisher { get } var nextPageMaxId: String? { get } - var maintainScrollPositionOfItem: CollectionItemIdentifier? { get } func request(maxId: String?, minId: String?) func viewedAtTop(indexPath: IndexPath) func select(indexPath: IndexPath) func canSelect(indexPath: IndexPath) -> Bool func viewModel(indexPath: IndexPath) -> CollectionItemViewModel + func toggleShowMoreForAll() } diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift index 180eaee..cfc45dd 100644 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift @@ -6,7 +6,8 @@ import ServiceLayer public struct CollectionItemIdentifier: Hashable { public let id: String public let kind: Kind - public let info: [InfoKey: AnyHashable] + public let pinned: Bool + public let showMoreToggled: Bool } public extension CollectionItemIdentifier { @@ -15,10 +16,6 @@ public extension CollectionItemIdentifier { case loadMore case account } - - enum InfoKey { - case pinned - } } extension CollectionItemIdentifier { @@ -27,15 +24,22 @@ extension CollectionItemIdentifier { case let .status(status, configuration): id = status.id kind = .status - info = configuration.isPinned ? [.pinned: true] : [:] + pinned = configuration.isPinned + showMoreToggled = configuration.showMoreToggled case let .loadMore(loadMore): id = loadMore.afterStatusId kind = .loadMore - info = [:] + pinned = false + showMoreToggled = false case let .account(account): id = account.id kind = .account - info = [:] + pinned = false + showMoreToggled = false } } + + public static func isSameExceptShowMoreToggled(lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id && lhs.kind == rhs.kind && lhs.pinned == rhs.pinned + } } diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionUpdate.swift b/ViewModels/Sources/ViewModels/Entities/CollectionUpdate.swift new file mode 100644 index 0000000..486cb7a --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/CollectionUpdate.swift @@ -0,0 +1,6 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +public struct CollectionUpdate: Hashable { + public let items: [[CollectionItemIdentifier]] + public let maintainScrollPosition: CollectionItemIdentifier? +} diff --git a/ViewModels/Sources/ViewModels/Entities/ShowMoreForAllState.swift b/ViewModels/Sources/ViewModels/Entities/ShowMoreForAllState.swift new file mode 100644 index 0000000..c26fae7 --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/ShowMoreForAllState.swift @@ -0,0 +1,7 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +public enum ShowMoreForAllState { + case hidden + case showMore + case showLess +} diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index e43cc94..8476976 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -41,14 +41,18 @@ final public class ProfileViewModel { } extension ProfileViewModel: CollectionViewModel { - public var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> { - collectionViewModel.flatMap(\.sections).eraseToAnyPublisher() + public var updates: AnyPublisher { + collectionViewModel.flatMap(\.updates).eraseToAnyPublisher() } public var title: AnyPublisher { $accountViewModel.compactMap { $0?.accountName }.eraseToAnyPublisher() } + public var showMoreForAll: AnyPublisher { + collectionViewModel.flatMap(\.showMoreForAll).eraseToAnyPublisher() + } + public var alertItems: AnyPublisher { collectionViewModel.flatMap(\.alertItems).eraseToAnyPublisher() } @@ -70,10 +74,6 @@ extension ProfileViewModel: CollectionViewModel { collectionViewModel.value.nextPageMaxId } - public var maintainScrollPositionOfItem: CollectionItemIdentifier? { - collectionViewModel.value.maintainScrollPositionOfItem - } - public func request(maxId: String?, minId: String?) { if case .statuses = collection, maxId == nil { profileService.fetchPinnedStatuses() @@ -100,4 +100,8 @@ extension ProfileViewModel: CollectionViewModel { public func viewModel(indexPath: IndexPath) -> CollectionItemViewModel { collectionViewModel.value.viewModel(indexPath: indexPath) } + + public func toggleShowMoreForAll() { + collectionViewModel.value.toggleShowMoreForAll() + } } diff --git a/ViewModels/Sources/ViewModels/StatusViewModel.swift b/ViewModels/Sources/ViewModels/StatusViewModel.swift index f429645..e58e1f4 100644 --- a/ViewModels/Sources/ViewModels/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusViewModel.swift @@ -18,14 +18,15 @@ public struct StatusViewModel: CollectionItemViewModel { public let pollOptionTitles: [String] public let pollEmoji: [Emoji] public var configuration = CollectionItem.StatusConfiguration.default - public var sensitiveContentToggled = false public let events: AnyPublisher, Never> private let statusService: StatusService private let eventsSubject = PassthroughSubject, Never>() + private let identification: Identification - init(statusService: StatusService) { + init(statusService: StatusService, identification: Identification) { self.statusService = statusService + self.identification = identification content = statusService.status.displayStatus.content.attributed contentEmoji = statusService.status.displayStatus.emojis displayName = statusService.status.displayStatus.account.displayName == "" @@ -47,11 +48,13 @@ public struct StatusViewModel: CollectionItemViewModel { } public extension StatusViewModel { - var shouldDisplaySensitiveContent: Bool { - if statusService.status.displayStatus.sensitive { - return sensitiveContentToggled + var shouldShowMore: Bool { + guard statusService.status.spoilerText != "" else { return true } + + if identification.identity.preferences.readingExpandSpoilers { + return !configuration.showMoreToggled } else { - return true + return configuration.showMoreToggled } } @@ -104,6 +107,13 @@ public extension StatusViewModel { } } + func toggleShowMore() { + eventsSubject.send( + statusService.toggleShowMore() + .map { _ in CollectionItemEvent.ignorableOutput } + .eraseToAnyPublisher()) + } + func urlSelected(_ url: URL) { eventsSubject.send( statusService.navigationService.item(url: url) diff --git a/Views/Status/StatusView.swift b/Views/Status/StatusView.swift index aa4d6cf..bc2a69d 100644 --- a/Views/Status/StatusView.swift +++ b/Views/Status/StatusView.swift @@ -14,14 +14,14 @@ class StatusView: UIView { @IBOutlet weak var accountLabel: UILabel! @IBOutlet weak var timeLabel: UILabel! @IBOutlet weak var spoilerTextLabel: UILabel! - @IBOutlet weak var toggleSensitiveContentButton: UIButton! + @IBOutlet weak var toggleShowMoreButton: UIButton! @IBOutlet weak var replyButton: UIButton! @IBOutlet weak var reblogButton: UIButton! @IBOutlet weak var favoriteButton: UIButton! @IBOutlet weak var shareButton: UIButton! @IBOutlet weak var attachmentsView: AttachmentsView! @IBOutlet weak var cardView: CardView! - @IBOutlet weak var sensitiveContentView: UIStackView! + @IBOutlet weak var showMoreView: UIStackView! @IBOutlet weak var hasReplyFollowingView: UIView! @IBOutlet weak var inReplyToView: UIView! @IBOutlet weak var avatarReplyContextView: UIView! @@ -65,7 +65,7 @@ class StatusView: UIView { override func layoutSubviews() { super.layoutSubviews() - for button: UIButton in [toggleSensitiveContentButton] where button.frame.height != 0 { + for button: UIButton in [toggleShowMoreButton] where button.frame.height != 0 { button.layer.cornerRadius = button.frame.height / 2 } } @@ -141,6 +141,10 @@ private extension StatusView { avatarButton.addAction(accountAction, for: .touchUpInside) contextParentAvatarButton.addAction(accountAction, for: .touchUpInside) + toggleShowMoreButton.addAction( + UIAction { [weak self] _ in self?.statusConfiguration.viewModel.toggleShowMore() }, + for: .touchUpInside) + cardView.button.addAction( UIAction { [weak self] _ in guard @@ -229,8 +233,8 @@ private extension StatusView { mutableSpoilerText.resizeAttachments(toLineHeight: spoilerTextLabel.font.lineHeight) spoilerTextLabel.attributedText = mutableSpoilerText spoilerTextLabel.isHidden = !viewModel.sensitive || spoilerTextLabel.text == "" - toggleSensitiveContentButton.setTitle( - viewModel.shouldDisplaySensitiveContent + toggleShowMoreButton.setTitle( + viewModel.shouldShowMore ? NSLocalizedString("status.show-less", comment: "") : NSLocalizedString("status.show-more", comment: ""), for: .normal) @@ -242,7 +246,7 @@ private extension StatusView { applicationButton.setTitle(viewModel.applicationName, for: .normal) applicationButton.isEnabled = viewModel.applicationURL != nil avatarImageView.kf.setImage(with: viewModel.avatarURL) - toggleSensitiveContentButton.isHidden = !viewModel.sensitive + toggleShowMoreButton.isHidden = !viewModel.sensitive replyButton.setTitle(viewModel.repliesCount == 0 ? "" : String(viewModel.repliesCount), for: .normal) reblogButton.setTitle(viewModel.reblogsCount == 0 ? "" : String(viewModel.reblogsCount), for: .normal) setReblogButtonColor(reblogged: viewModel.reblogged) @@ -303,7 +307,7 @@ private extension StatusView { cardView.viewModel = viewModel.cardViewModel cardView.isHidden = viewModel.cardViewModel == nil - sensitiveContentView.isHidden = !viewModel.shouldDisplaySensitiveContent + showMoreView.isHidden = !viewModel.shouldShowMore inReplyToView.isHidden = !viewModel.configuration.isReplyInContext diff --git a/Views/Status/StatusView.xib b/Views/Status/StatusView.xib index 9f2be47..b7a87f4 100644 --- a/Views/Status/StatusView.xib +++ b/Views/Status/StatusView.xib @@ -45,12 +45,12 @@ - + - +