Content warnings

This commit is contained in:
Justin Mazzocchi 2020-10-07 14:06:26 -07:00
parent da62bb6743
commit fa4d666f8d
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
27 changed files with 329 additions and 84 deletions

View File

@ -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)

View File

@ -149,6 +149,39 @@ public extension ContentDatabase {
.eraseToAnyPublisher()
}
func toggleShowMore(id: Status.Id) -> AnyPublisher<Never, Error> {
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<Status.Id>) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
for id in ids {
try StatusShowMoreToggle(statusId: id).save($0)
}
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func showLess(ids: Set<Status.Id>) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher(
updates: StatusShowMoreToggle
.filter(ids.contains(StatusShowMoreToggle.Columns.statusId))
.deleteAll)
.ignoreOutput()
.eraseToAnyPublisher()
}
func append(accounts: [Account], toList list: AccountList) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
try list.save($0)

View File

@ -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))
}

View File

@ -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]))
}

View File

@ -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]))
}

View File

@ -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<StatusRecord>) -> QueryInterfaceRequest<Self> {
@ -25,4 +30,8 @@ extension StatusInfo {
var filterableContent: String {
(record.filterableContent + (reblogRecord?.filterableContent ?? [])).joined(separator: " ")
}
var showMoreToggled: Bool {
showMoreToggle != nil || reblogShowMoreToggle != nil
}
}

View File

@ -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]))

View File

@ -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()
}
}

View File

@ -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]

View File

@ -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)
}

View File

@ -0,0 +1,49 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionItemIdentifier> {
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<Int, CollectionItemIdentifier>,
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)
}
}
}

View File

@ -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 = "<group>"; };
D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = "<group>"; };
@ -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 = "<group>";
};
D0A1F4F5252E7D2A004435BF /* Data Sources */ = {
isa = PBXGroup;
children = (
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */,
);
path = "Data Sources";
sourceTree = "<group>";
};
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 */,

View File

@ -41,4 +41,8 @@ extension AccountListService: CollectionService {
.flatMap { contentDatabase.append(accounts: $0.result, toList: list) }
.eraseToAnyPublisher()
}
public func toggleShowMore(id: Status.Id) -> AnyPublisher<Never, Error> {
contentDatabase.toggleShowMore(id: id)
}
}

View File

@ -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<String, Never> { get }
var navigationService: NavigationService { get }
func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error>
func toggleShowMore(id: Status.Id) -> AnyPublisher<Never, Error>
}
extension CollectionService {

View File

@ -31,4 +31,16 @@ extension ContextService: CollectionService {
.flatMap { contentDatabase.insert(context: $0, parentId: id) })
.eraseToAnyPublisher()
}
public func toggleShowMore(id: Status.Id) -> AnyPublisher<Never, Error> {
contentDatabase.toggleShowMore(id: id)
}
public func showMore(ids: Set<Status.Id>) -> AnyPublisher<Never, Error> {
contentDatabase.showMore(ids: ids)
}
public func showLess(ids: Set<Status.Id>) -> AnyPublisher<Never, Error> {
contentDatabase.showLess(ids: ids)
}
}

View File

@ -24,6 +24,10 @@ public struct StatusService {
}
public extension StatusService {
func toggleShowMore() -> AnyPublisher<Never, Error> {
contentDatabase.toggleShowMore(id: status.displayStatus.id)
}
func toggleFavorited() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(status.displayStatus.favourited
? StatusEndpoint.unfavourite(id: status.displayStatus.id)

View File

@ -44,4 +44,8 @@ extension TimelineService: CollectionService {
.flatMap { contentDatabase.insert(statuses: $0.result, timeline: timeline) }
.eraseToAnyPublisher()
}
public func toggleShowMore(id: Status.Id) -> AnyPublisher<Never, Error> {
contentDatabase.toggleShowMore(id: id)
}
}

View File

@ -12,30 +12,9 @@ class TableViewController: UITableViewController {
private let webfingerIndicatorView = WebfingerIndicatorView()
private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [CollectionItemIdentifier: CGFloat]]()
private let dataSourceQueue =
DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue")
private lazy var dataSource: UITableViewDiffableDataSource<Int, CollectionItemIdentifier> = {
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)

View File

@ -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<CollectionItemEvent, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>()
private let showMoreForAllSubject: CurrentValueSubject<ShowMoreForAllState, Never>
private var maintainScrollPosition: CollectionItemIdentifier?
private var topVisibleIndexPath = IndexPath(item: 0, section: 0)
private var lastSelectedLoadMore: LoadMore?
private var cancellables = Set<AnyCancellable>()
@ -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<CollectionUpdate, Never> {
items.map { [weak self] in
CollectionUpdate(items: $0.map { $0.map(CollectionItemIdentifier.init(item:)) },
maintainScrollPosition: self?.maintainScrollPosition)
}
.eraseToAnyPublisher()
}
public var title: AnyPublisher<String, Never> { collectionService.title }
public var showMoreForAll: AnyPublisher<ShowMoreForAllState, Never> {
showMoreForAllSubject.eraseToAnyPublisher()
}
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
public var loading: AnyPublisher<Bool, Never> { 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: {

View File

@ -4,16 +4,17 @@ import Combine
import Foundation
public protocol CollectionViewModel {
var sections: AnyPublisher<[[CollectionItemIdentifier]], Never> { get }
var updates: AnyPublisher<CollectionUpdate, Never> { get }
var title: AnyPublisher<String, Never> { get }
var showMoreForAll: AnyPublisher<ShowMoreForAllState, Never> { get }
var alertItems: AnyPublisher<AlertItem, Never> { get }
var loading: AnyPublisher<Bool, Never> { get }
var events: AnyPublisher<CollectionItemEvent, Never> { 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()
}

View File

@ -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
}
}

View File

@ -0,0 +1,6 @@
// Copyright © 2020 Metabolist. All rights reserved.
public struct CollectionUpdate: Hashable {
public let items: [[CollectionItemIdentifier]]
public let maintainScrollPosition: CollectionItemIdentifier?
}

View File

@ -0,0 +1,7 @@
// Copyright © 2020 Metabolist. All rights reserved.
public enum ShowMoreForAllState {
case hidden
case showMore
case showLess
}

View File

@ -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<CollectionUpdate, Never> {
collectionViewModel.flatMap(\.updates).eraseToAnyPublisher()
}
public var title: AnyPublisher<String, Never> {
$accountViewModel.compactMap { $0?.accountName }.eraseToAnyPublisher()
}
public var showMoreForAll: AnyPublisher<ShowMoreForAllState, Never> {
collectionViewModel.flatMap(\.showMoreForAll).eraseToAnyPublisher()
}
public var alertItems: AnyPublisher<AlertItem, Never> {
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()
}
}

View File

@ -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<AnyPublisher<CollectionItemEvent, Error>, Never>
private let statusService: StatusService
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, 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)

View File

@ -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

View File

@ -45,12 +45,12 @@
<outlet property="nameDateView" destination="svp-hj-Xn6" id="SzA-ME-jU0"/>
<outlet property="reblogButton" destination="ZKl-Hp-Y42" id="bxe-wr-kB7"/>
<outlet property="replyButton" destination="6HD-MP-H72" id="5fb-z4-qlm"/>
<outlet property="sensitiveContentView" destination="BXI-3E-NWh" id="sXO-EM-fTI"/>
<outlet property="shareButton" destination="zAD-2Z-vhu" id="ZzV-wg-rOZ"/>
<outlet property="showMoreView" destination="BXI-3E-NWh" id="Zsm-ho-TbZ"/>
<outlet property="spoilerTextLabel" destination="5Gq-2q-Cvx" id="owo-VU-cIm"/>
<outlet property="timeApplicationDividerView" destination="hYj-vy-Net" id="F8o-xH-FFP"/>
<outlet property="timeLabel" destination="FEN-6u-xs5" id="epT-vQ-T9R"/>
<outlet property="toggleSensitiveContentButton" destination="XqE-Oj-oxH" id="EQB-cR-eOL"/>
<outlet property="toggleShowMoreButton" destination="XqE-Oj-oxH" id="D1n-WP-y9y"/>
<outletCollection property="separatorConstraints" destination="VEz-6j-37B" collectionClass="NSMutableArray" id="liC-8J-DV0"/>
<outletCollection property="separatorConstraints" destination="H9G-jZ-cek" collectionClass="NSMutableArray" id="E4b-tA-2gG"/>
</connections>