Show threads on Conversation screen
This commit is contained in:
parent
122cce3bc7
commit
bcc023a127
|
@ -272,6 +272,8 @@
|
|||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
|
||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
|
||||
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */; };
|
||||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */; };
|
||||
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */; };
|
||||
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; };
|
||||
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; };
|
||||
D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */; };
|
||||
|
@ -634,6 +636,8 @@
|
|||
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
|
||||
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
|
||||
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = "<group>"; };
|
||||
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ExpandThreadTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = "<group>"; };
|
||||
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
|
||||
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = "<group>"; };
|
||||
|
@ -1043,6 +1047,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */,
|
||||
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */,
|
||||
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */,
|
||||
);
|
||||
path = Conversation;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1746,6 +1752,7 @@
|
|||
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */,
|
||||
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
|
||||
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
|
||||
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */,
|
||||
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
|
||||
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
|
||||
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */,
|
||||
|
@ -2013,6 +2020,7 @@
|
|||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
||||
|
|
|
@ -8,23 +8,30 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import CoreData
|
||||
|
||||
class ConversationNode {
|
||||
let status: StatusMO
|
||||
var children: [ConversationNode]
|
||||
|
||||
init(status: StatusMO) {
|
||||
self.status = status
|
||||
self.children = []
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationTableViewController: EnhancedTableViewController {
|
||||
|
||||
static let showPostsImage = UIImage(systemName: "eye.fill")!
|
||||
static let hidePostsImage = UIImage(systemName: "eye.slash.fill")!
|
||||
|
||||
static let bottomSeparatorTag = 101
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
let mainStatusID: String
|
||||
let mainStatusState: StatusState
|
||||
var statuses: [(id: String, state: StatusState)] = [] {
|
||||
didSet {
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||
|
||||
var showStatusesAutomatically = false
|
||||
var visibilityBarButtonItem: UIBarButtonItem!
|
||||
|
@ -45,7 +52,8 @@ class ConversationTableViewController: EnhancedTableViewController {
|
|||
|
||||
deinit {
|
||||
guard let persistentContainer = mastodonController?.persistentContainer else { return }
|
||||
for (id, _) in statuses {
|
||||
let snapshot = dataSource.snapshot()
|
||||
for case let .status(id: id, state: _) in snapshot.itemIdentifiers {
|
||||
persistentContainer.status(for: id)?.decrementReferenceCount()
|
||||
}
|
||||
}
|
||||
|
@ -60,13 +68,72 @@ class ConversationTableViewController: EnhancedTableViewController {
|
|||
|
||||
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell")
|
||||
tableView.register(UINib(nibName: "ConversationMainStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "mainStatusCell")
|
||||
tableView.register(UINib(nibName: "ExpandThreadTableViewCell", bundle: .main), forCellReuseIdentifier: "expandThreadCell")
|
||||
|
||||
tableView.prefetchDataSource = self
|
||||
|
||||
visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
|
||||
tableView.backgroundColor = .secondarySystemBackground
|
||||
// separators are disabled on the table view so we can re-add them ourselves
|
||||
// so they're not inserted in between statuses in the ame sub-thread
|
||||
tableView.separatorStyle = .none
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
switch item {
|
||||
case let .status(id: id, state: state):
|
||||
let rowsInSection = self.dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section)
|
||||
let firstInSection = indexPath.row == 0
|
||||
let lastInSection = indexPath.row == rowsInSection - 1
|
||||
|
||||
let identifier = id == self.mainStatusID ? "mainStatusCell" : "statusCell"
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! BaseStatusTableViewCell
|
||||
|
||||
cell.delegate = self
|
||||
cell.showStatusAutomatically = self.showStatusesAutomatically
|
||||
|
||||
if let cell = cell as? TimelineStatusTableViewCell {
|
||||
cell.showReplyIndicator = false
|
||||
}
|
||||
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
|
||||
cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection)
|
||||
if lastInSection {
|
||||
if cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag) == nil {
|
||||
let separator = UIView()
|
||||
separator.tag = ConversationTableViewController.bottomSeparatorTag
|
||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||
separator.backgroundColor = tableView.separatorColor
|
||||
cell.addSubview(separator)
|
||||
NSLayoutConstraint.activate([
|
||||
separator.heightAnchor.constraint(equalToConstant: 0.5),
|
||||
separator.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
|
||||
separator.leftAnchor.constraint(equalTo: cell.leftAnchor, constant: cell.separatorInset.left),
|
||||
separator.rightAnchor.constraint(equalTo: cell.rightAnchor),
|
||||
])
|
||||
}
|
||||
} else {
|
||||
cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag)?.removeFromSuperview()
|
||||
}
|
||||
|
||||
return cell
|
||||
|
||||
case let .expandThread(childThreads: childThreads):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "expandThreadCell", for: indexPath) as! ExpandThreadTableViewCell
|
||||
cell.updateUI(childThreads: childThreads)
|
||||
return cell
|
||||
}
|
||||
})
|
||||
|
||||
let initialImage = showStatusesAutomatically ? ConversationTableViewController.hidePostsImage : ConversationTableViewController.showPostsImage
|
||||
visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
|
||||
navigationItem.rightBarButtonItem = visibilityBarButtonItem
|
||||
|
||||
statuses = [(mainStatusID, mainStatusState)]
|
||||
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.statuses])
|
||||
snapshot.appendItems([mainStatusItem], toSection: .statuses)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
guard let mainStatus = self.mastodonController.persistentContainer.status(for: self.mainStatusID) else {
|
||||
fatalError("Missing cached status \(self.mainStatusID)")
|
||||
|
@ -80,19 +147,39 @@ class ConversationTableViewController: EnhancedTableViewController {
|
|||
|
||||
let parents = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors)
|
||||
let parentStatuses = context.ancestors.filter { parents.contains($0.id) }
|
||||
self.mastodonController.persistentContainer.addAll(statuses: parentStatuses) {
|
||||
self.mastodonController.persistentContainer.addAll(statuses: context.descendants) {
|
||||
self.statuses = parents.map { ($0, .unknown) } + self.statuses + context.descendants.map { ($0.id, .unknown) }
|
||||
let indexPath = IndexPath(row: parents.count, section: 0)
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
|
||||
|
||||
// todo: should this really be blindly adding all the descendants?
|
||||
self.mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) {
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
snapshot.insertItems(parents.map { .status(id: $0, state: .unknown) }, beforeItem: mainStatusItem)
|
||||
|
||||
// fetch all descendant status managed objects
|
||||
let descendantIDs = context.descendants.map(\.id)
|
||||
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id in %@", descendantIDs)
|
||||
|
||||
if let descendants = try? self.mastodonController.persistentContainer.viewContext.fetch(request) {
|
||||
// convert array of descendant statuses into tree of sub-threads
|
||||
let childThreads = self.getDescendantThreads(mainStatus: mainStatus, descendants: descendants)
|
||||
|
||||
// convert sub-threads into items for section and add to snapshot
|
||||
self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot)
|
||||
}
|
||||
|
||||
self.dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
// ensure that the main status is on-screen after newly loaded statuses are added
|
||||
// todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)?
|
||||
if let indexPath = self.dataSource.indexPath(for: mainStatusItem) {
|
||||
self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
|
||||
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
|
||||
var statuses = statuses
|
||||
var parents = [String]()
|
||||
|
||||
|
@ -107,38 +194,99 @@ class ConversationTableViewController: EnhancedTableViewController {
|
|||
return parents
|
||||
}
|
||||
|
||||
// MARK: - Table view data source
|
||||
private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] {
|
||||
var descendants = descendants
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 1
|
||||
func removeAllInReplyTo(id: String) -> [StatusMO] {
|
||||
let statuses = descendants.filter { $0.inReplyToID == id }
|
||||
descendants.removeAll { $0.inReplyToID == id }
|
||||
return statuses
|
||||
}
|
||||
|
||||
var nodes: [String: ConversationNode] = [
|
||||
mainStatus.id: ConversationNode(status: mainStatus)
|
||||
]
|
||||
|
||||
var idsToCheck = [mainStatusID]
|
||||
|
||||
while !idsToCheck.isEmpty {
|
||||
let inReplyToID = idsToCheck.removeFirst()
|
||||
let nodeForID = nodes[inReplyToID]!
|
||||
|
||||
let inReply = removeAllInReplyTo(id: inReplyToID)
|
||||
for reply in inReply {
|
||||
idsToCheck.append(reply.id)
|
||||
|
||||
let replyNode = ConversationNode(status: reply)
|
||||
nodes[reply.id] = replyNode
|
||||
|
||||
nodeForID.children.append(replyNode)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes[mainStatusID]!.children
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return statuses.count
|
||||
private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
|
||||
var childThreads = childThreads
|
||||
|
||||
// child threads by the same author as the main status come first
|
||||
let pivotIndex = childThreads.partition(by: { $0.status.account.id != mainStatus.account.id })
|
||||
|
||||
// within each group, child threads are sorted chronologically
|
||||
childThreads[0..<pivotIndex].sort(by: { $0.status.createdAt < $1.status.createdAt })
|
||||
childThreads[pivotIndex...].sort(by: { $0.status.createdAt < $1.status.createdAt })
|
||||
|
||||
for node in childThreads {
|
||||
snapshot.appendSections([.childThread(firstStatusID: node.status.id)])
|
||||
snapshot.appendItems([.status(id: node.status.id, state: .unknown)])
|
||||
|
||||
var currentNode = node
|
||||
while true {
|
||||
let next: ConversationNode
|
||||
|
||||
if currentNode.children.count == 0 {
|
||||
break
|
||||
} else if currentNode.children.count == 1 {
|
||||
next = currentNode.children[0]
|
||||
} else {
|
||||
let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id })
|
||||
if sameAuthorStatuses.count == 1 {
|
||||
next = sameAuthorStatuses[0]
|
||||
} else {
|
||||
snapshot.appendItems([.expandThread(childThreads: currentNode.children)])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
currentNode = next
|
||||
snapshot.appendItems([.status(id: next.status.id, state: .unknown)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let (id, state) = statuses[indexPath.row]
|
||||
|
||||
if id == mainStatusID {
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "mainStatusCell", for: indexPath) as? ConversationMainStatusTableViewCell else { fatalError() }
|
||||
cell.selectionStyle = .none
|
||||
cell.showStatusAutomatically = showStatusesAutomatically
|
||||
cell.delegate = self
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
return cell
|
||||
} else {
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
|
||||
cell.showStatusAutomatically = showStatusesAutomatically
|
||||
cell.showReplyIndicator = false
|
||||
cell.delegate = self
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
return cell
|
||||
func item(for indexPath: IndexPath) -> (id: String, state: StatusState)? {
|
||||
return self.dataSource.itemIdentifier(for: indexPath).flatMap { (item) in
|
||||
switch item {
|
||||
case let .status(id: id, state: state):
|
||||
return (id: id, state: state)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table view delegate
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if case .expandThread = dataSource.itemIdentifier(for: indexPath),
|
||||
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
|
||||
self.selected(status: id, state: state)
|
||||
} else {
|
||||
super.tableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
@ -154,7 +302,8 @@ class ConversationTableViewController: EnhancedTableViewController {
|
|||
@objc func toggleVisibilityButtonPressed() {
|
||||
showStatusesAutomatically = !showStatusesAutomatically
|
||||
|
||||
for (_, state) in statuses where state.collapsible == true {
|
||||
let snapshot = dataSource.snapshot()
|
||||
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {
|
||||
state.collapsed = !showStatusesAutomatically
|
||||
}
|
||||
|
||||
|
@ -178,6 +327,48 @@ class ConversationTableViewController: EnhancedTableViewController {
|
|||
|
||||
}
|
||||
|
||||
extension ConversationTableViewController {
|
||||
enum Section: Hashable {
|
||||
case statuses
|
||||
case childThread(firstStatusID: String)
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case status(id: String, state: StatusState)
|
||||
case expandThread(childThreads: [ConversationNode])
|
||||
|
||||
static func == (lhs: Item, rhs: Item) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
||||
return a == b
|
||||
case let (.expandThread(childThreads: a), .expandThread(childThreads: b)):
|
||||
return zip(a, b).allSatisfy { $0.status.id == $1.status.id }
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case let .status(id: id, state: _):
|
||||
hasher.combine("status")
|
||||
hasher.combine(id)
|
||||
case let .expandThread(childThreads: children):
|
||||
hasher.combine("expandThread")
|
||||
hasher.combine(children.map(\.status.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationTableViewController: TuskerNavigationDelegate {
|
||||
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
|
||||
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
|
||||
// transfer show statuses automatically state when showing new conversation
|
||||
vc.showStatusesAutomatically = self.showStatusesAutomatically
|
||||
return vc
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationTableViewController: StatusTableViewCellDelegate {
|
||||
var apiController: MastodonController { mastodonController }
|
||||
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
|
||||
|
@ -189,12 +380,12 @@ extension ConversationTableViewController: StatusTableViewCellDelegate {
|
|||
|
||||
extension ConversationTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
let ids = indexPaths.map { statuses[$0.row].id }
|
||||
let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
|
||||
prefetchStatuses(with: ids)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
let ids: [String] = indexPaths.compactMap { statuses[$0.row].id }
|
||||
let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
|
||||
cancelPrefetchingStatuses(with: ids)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
//
|
||||
// ExpandThreadTableViewCell.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/30/21.
|
||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ExpandThreadTableViewCell: UITableViewCell {
|
||||
|
||||
@IBOutlet weak var avatarContainerView: UIView!
|
||||
@IBOutlet weak var avatarContainerWidthConstraint: NSLayoutConstraint!
|
||||
@IBOutlet weak var replyCountLabel: UILabel!
|
||||
var avatarImageViews: [UIImageView] = []
|
||||
|
||||
private var avatarRequests: [ImageCache.Request] = []
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
let prevThreadLinkView = UIView()
|
||||
prevThreadLinkView.translatesAutoresizingMaskIntoConstraints = false
|
||||
prevThreadLinkView.backgroundColor = tintColor.withAlphaComponent(0.5)
|
||||
prevThreadLinkView.layer.cornerRadius = 2.5
|
||||
prevThreadLinkView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||
contentView.addSubview(prevThreadLinkView)
|
||||
NSLayoutConstraint.activate([
|
||||
prevThreadLinkView.widthAnchor.constraint(equalToConstant: 5),
|
||||
prevThreadLinkView.centerXAnchor.constraint(equalTo: leadingAnchor, constant: 16 + 25),
|
||||
prevThreadLinkView.topAnchor.constraint(equalTo: topAnchor),
|
||||
prevThreadLinkView.bottomAnchor.constraint(equalTo: avatarContainerView.topAnchor, constant: -2),
|
||||
])
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
func updateUI(childThreads: [ConversationNode]) {
|
||||
let format = NSLocalizedString("expand threads count", comment: "expand conversation threads button label")
|
||||
replyCountLabel.text = String.localizedStringWithFormat(format, childThreads.count)
|
||||
|
||||
let accounts = childThreads.map(\.status.account).uniques().prefix(3)
|
||||
|
||||
avatarImageViews.forEach { $0.removeFromSuperview() }
|
||||
avatarImageViews = []
|
||||
|
||||
avatarRequests = []
|
||||
|
||||
let avatarImageSize: CGFloat = 44 - 12
|
||||
|
||||
if accounts.count == 1 {
|
||||
avatarContainerWidthConstraint.constant = avatarImageSize
|
||||
} else {
|
||||
avatarContainerWidthConstraint.constant = CGFloat(accounts.count) * avatarImageSize * 3 / 4
|
||||
}
|
||||
|
||||
for (index, account) in accounts.enumerated() {
|
||||
let accountImageView = UIImageView()
|
||||
accountImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
accountImageView.contentMode = .scaleAspectFit
|
||||
accountImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize
|
||||
accountImageView.layer.masksToBounds = true
|
||||
accountImageView.layer.borderWidth = 1
|
||||
accountImageView.layer.borderColor = UIColor.secondarySystemBackground.cgColor
|
||||
// need a solid background color so semi-transparent avatars don't look bad
|
||||
accountImageView.backgroundColor = .secondarySystemBackground
|
||||
avatarContainerView.addSubview(accountImageView)
|
||||
|
||||
avatarImageViews.append(accountImageView)
|
||||
|
||||
accountImageView.layer.zPosition = CGFloat(-index)
|
||||
|
||||
let xConstraint: NSLayoutConstraint
|
||||
if index == 0 {
|
||||
xConstraint = accountImageView.leadingAnchor.constraint(equalTo: avatarContainerView.leadingAnchor)
|
||||
} else if index == accounts.count - 1 {
|
||||
xConstraint = accountImageView.trailingAnchor.constraint(equalTo: avatarContainerView.trailingAnchor)
|
||||
} else {
|
||||
xConstraint = accountImageView.centerXAnchor.constraint(equalTo: avatarContainerView.centerXAnchor)
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
accountImageView.widthAnchor.constraint(equalToConstant: avatarImageSize),
|
||||
accountImageView.heightAnchor.constraint(equalToConstant: avatarImageSize),
|
||||
accountImageView.centerYAnchor.constraint(equalTo: avatarContainerView.centerYAnchor),
|
||||
xConstraint
|
||||
])
|
||||
|
||||
let req = ImageCache.avatars.get(account.avatar) { [weak accountImageView] (_, image) in
|
||||
DispatchQueue.main.async {
|
||||
accountImageView?.image = image
|
||||
}
|
||||
}
|
||||
if let req = req {
|
||||
avatarRequests.append(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func preferencesChanged() {
|
||||
avatarImageViews.forEach {
|
||||
$0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: $0)
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
avatarRequests.forEach { $0.cancel() }
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw" customClass="ExpandThreadTableViewCell" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
|
||||
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="IXi-sc-YIw">
|
||||
<rect key="frame" x="16" y="0.0" width="173" height="44"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="eFB-F1-d3A">
|
||||
<rect key="frame" x="0.0" y="6" width="100" height="32"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="32" id="g9U-u7-718"/>
|
||||
<constraint firstAttribute="width" constant="100" id="tiI-Rj-gjh"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2 replies" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Dcm-ll-GeE">
|
||||
<rect key="frame" x="108" y="12" width="65" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<color key="textColor" systemColor="systemBlueColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="44" id="jk2-uV-FdO"/>
|
||||
</constraints>
|
||||
</stackView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Kkt-hM-ScW">
|
||||
<rect key="frame" x="16" y="43.5" width="304" height="0.5"/>
|
||||
<color key="backgroundColor" systemColor="separatorColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="0.5" id="Fkq-bT-IYv"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="Kkt-hM-ScW" secondAttribute="bottom" id="AvY-H1-0YN"/>
|
||||
<constraint firstItem="Kkt-hM-ScW" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="E5g-hz-SLI"/>
|
||||
<constraint firstItem="IXi-sc-YIw" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" id="SRF-Zx-Y0R"/>
|
||||
<constraint firstAttribute="trailing" secondItem="Kkt-hM-ScW" secondAttribute="trailing" id="YML-R1-ezq"/>
|
||||
<constraint firstItem="IXi-sc-YIw" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="iD5-Av-ORS"/>
|
||||
<constraint firstAttribute="bottom" secondItem="IXi-sc-YIw" secondAttribute="bottom" id="kpD-6Q-qKi"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
|
||||
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
|
||||
<connections>
|
||||
<outlet property="avatarContainerView" destination="eFB-F1-d3A" id="xGo-40-nn7"/>
|
||||
<outlet property="avatarContainerWidthConstraint" destination="tiI-Rj-gjh" id="34n-ev-EKi"/>
|
||||
<outlet property="replyCountLabel" destination="Dcm-ll-GeE" id="E4m-xk-DiQ"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="132" y="132"/>
|
||||
</tableViewCell>
|
||||
</objects>
|
||||
<resources>
|
||||
<systemColor name="secondarySystemBackgroundColor">
|
||||
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
<systemColor name="separatorColor">
|
||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
<systemColor name="systemBlueColor">
|
||||
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
|
@ -12,6 +12,8 @@ import Pachyderm
|
|||
|
||||
protocol TuskerNavigationDelegate: UIViewController {
|
||||
var apiController: MastodonController { get }
|
||||
|
||||
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController
|
||||
}
|
||||
|
||||
extension TuskerNavigationDelegate {
|
||||
|
@ -64,19 +66,16 @@ extension TuskerNavigationDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
|
||||
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
||||
}
|
||||
|
||||
func selected(status statusID: String) {
|
||||
self.selected(status: statusID, state: .unknown)
|
||||
}
|
||||
|
||||
func selected(status statusID: String, state: StatusState) {
|
||||
// todo: is this necessary? should the conversation main status cell prevent this
|
||||
// don't open if the conversation is the same as the current one
|
||||
if let conversationController = self as? ConversationTableViewController,
|
||||
conversationController.mainStatusID == statusID {
|
||||
return
|
||||
}
|
||||
|
||||
show(ConversationTableViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
|
||||
show(conversation(mainStatusID: statusID, state: state), sender: self)
|
||||
}
|
||||
|
||||
func compose(editing draft: Draft) {
|
||||
|
|
|
@ -38,6 +38,8 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
@IBOutlet weak var favoriteButton: UIButton!
|
||||
@IBOutlet weak var reblogButton: UIButton!
|
||||
@IBOutlet weak var moreButton: UIButton!
|
||||
private(set) var prevThreadLinkView: UIView?
|
||||
private(set) var nextThreadLinkView: UIView?
|
||||
|
||||
var statusID: String!
|
||||
var accountID: String!
|
||||
|
@ -278,6 +280,52 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||
}
|
||||
|
||||
func setShowThreadLinks(prev: Bool, next: Bool) {
|
||||
if prev {
|
||||
if let prevThreadLinkView = prevThreadLinkView {
|
||||
prevThreadLinkView.isHidden = false
|
||||
} else {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = tintColor.withAlphaComponent(0.5)
|
||||
view.layer.cornerRadius = 2.5
|
||||
view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||
prevThreadLinkView = view
|
||||
addSubview(view)
|
||||
NSLayoutConstraint.activate([
|
||||
view.widthAnchor.constraint(equalToConstant: 5),
|
||||
view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
|
||||
view.topAnchor.constraint(equalTo: topAnchor),
|
||||
view.bottomAnchor.constraint(equalTo: avatarImageView.topAnchor, constant: -2),
|
||||
])
|
||||
}
|
||||
} else {
|
||||
prevThreadLinkView?.isHidden = true
|
||||
}
|
||||
|
||||
if next {
|
||||
if let nextThreadLinkView = nextThreadLinkView {
|
||||
nextThreadLinkView.isHidden = false
|
||||
} else {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = tintColor.withAlphaComponent(0.5)
|
||||
view.layer.cornerRadius = 2.5
|
||||
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
nextThreadLinkView = view
|
||||
addSubview(view)
|
||||
NSLayoutConstraint.activate([
|
||||
view.widthAnchor.constraint(equalToConstant: 5),
|
||||
view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
|
||||
view.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 2),
|
||||
view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
} else {
|
||||
nextThreadLinkView?.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
|
|
|
@ -29,5 +29,21 @@
|
|||
<string>%u people</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>expand threads count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@replies@</string>
|
||||
<key>replies</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>u</string>
|
||||
<key>one</key>
|
||||
<string>1 reply</string>
|
||||
<key>other</key>
|
||||
<string>%u replies</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
Loading…
Reference in New Issue