From bcc023a1271ebb17fb035b60f1f633d06b257838 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 31 Jan 2021 17:42:29 -0500 Subject: [PATCH] Show threads on Conversation screen --- Tusker.xcodeproj/project.pbxproj | 8 + .../ConversationTableViewController.swift | 279 +++++++++++++++--- .../ExpandThreadTableViewCell.swift | 112 +++++++ .../ExpandThreadTableViewCell.xib | 80 +++++ Tusker/TuskerNavigationDelegate.swift | 15 +- .../Status/BaseStatusTableViewCell.swift | 48 +++ Tusker/en.lproj/Localizable.stringsdict | 16 + 7 files changed, 506 insertions(+), 52 deletions(-) create mode 100644 Tusker/Screens/Conversation/ExpandThreadTableViewCell.swift create mode 100644 Tusker/Screens/Conversation/ExpandThreadTableViewCell.xib diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 9bca20f6..8f86ad7d 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = ""; }; D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = ""; }; + D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadTableViewCell.swift; sourceTree = ""; }; + D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ExpandThreadTableViewCell.xib; sourceTree = ""; }; D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = ""; }; D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = ""; }; @@ -1043,6 +1047,8 @@ isa = PBXGroup; children = ( D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */, + D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */, + D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */, ); path = Conversation; sourceTree = ""; @@ -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 */, diff --git a/Tusker/Screens/Conversation/ConversationTableViewController.swift b/Tusker/Screens/Conversation/ConversationTableViewController.swift index f6caa388..0658ec66 100644 --- a/Tusker/Screens/Conversation/ConversationTableViewController.swift +++ b/Tusker/Screens/Conversation/ConversationTableViewController.swift @@ -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! 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(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() + 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.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 - - override func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return statuses.count + private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] { + var descendants = descendants + + 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, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let (id, state) = statuses[indexPath.row] + private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot) { + var childThreads = childThreads - 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 + // 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.. (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) } } diff --git a/Tusker/Screens/Conversation/ExpandThreadTableViewCell.swift b/Tusker/Screens/Conversation/ExpandThreadTableViewCell.swift new file mode 100644 index 00000000..88d823ba --- /dev/null +++ b/Tusker/Screens/Conversation/ExpandThreadTableViewCell.swift @@ -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() } + } + +} diff --git a/Tusker/Screens/Conversation/ExpandThreadTableViewCell.xib b/Tusker/Screens/Conversation/ExpandThreadTableViewCell.xib new file mode 100644 index 00000000..bb5d64d6 --- /dev/null +++ b/Tusker/Screens/Conversation/ExpandThreadTableViewCell.xib @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 800d5ec6..e8c55bb2 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -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) { diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index cde299e8..5799057c 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -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() diff --git a/Tusker/en.lproj/Localizable.stringsdict b/Tusker/en.lproj/Localizable.stringsdict index febdd157..12dbedae 100644 --- a/Tusker/en.lproj/Localizable.stringsdict +++ b/Tusker/en.lproj/Localizable.stringsdict @@ -29,5 +29,21 @@ %u people + expand threads count + + NSStringLocalizedFormatKey + %#@replies@ + replies + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + u + one + 1 reply + other + %u replies + +