mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Content warnings
This commit is contained in:
parent
da62bb6743
commit
fa4d666f8d
27 changed files with 329 additions and 84 deletions
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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]))
|
||||
}
|
||||
|
|
|
@ -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]))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]))
|
||||
|
|
25
DB/Sources/DB/Content/StatusShowMoreToggle.swift
Normal file
25
DB/Sources/DB/Content/StatusShowMoreToggle.swift
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
49
Data Sources/TableViewDataSource.swift
Normal file
49
Data Sources/TableViewDataSource.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 */,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
public struct CollectionUpdate: Hashable {
|
||||
public let items: [[CollectionItemIdentifier]]
|
||||
public let maintainScrollPosition: CollectionItemIdentifier?
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
public enum ShowMoreForAllState {
|
||||
case hidden
|
||||
case showMore
|
||||
case showLess
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue