Compare commits
8 Commits
ca5ac8b826
...
2b22180191
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 2b22180191 | |
Shadowfacts | 654b5d9c59 | |
Shadowfacts | 777d1f378c | |
Shadowfacts | 3b132ab4dc | |
Shadowfacts | d1083116e0 | |
Shadowfacts | 7b79cec0ed | |
Shadowfacts | 50cbbb86fc | |
Shadowfacts | 5a914ea5a3 |
|
@ -12,6 +12,7 @@ public class Mention: Codable {
|
|||
public let url: URL
|
||||
public let username: String
|
||||
public let acct: String
|
||||
/// The instance-local ID of the user being mentioned.
|
||||
public let id: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
|
|
@ -154,7 +154,6 @@
|
|||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
|
||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
|
||||
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; };
|
||||
D65234C9256189D0001AF9CF /* TimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */; };
|
||||
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */; };
|
||||
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; };
|
||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
|
||||
|
@ -559,7 +558,6 @@
|
|||
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
||||
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
|
||||
D64F80E1215875CC00BEF393 /* XCBActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActionType.swift; sourceTree = "<group>"; };
|
||||
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeTableViewController.swift; sourceTree = "<group>"; };
|
||||
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
|
||||
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
|
||||
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
|
||||
|
@ -1523,7 +1521,6 @@
|
|||
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
|
||||
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
|
||||
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
|
||||
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
|
||||
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
|
||||
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
|
||||
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
|
||||
|
@ -2080,7 +2077,6 @@
|
|||
D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */,
|
||||
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
|
||||
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
|
||||
D65234C9256189D0001AF9CF /* TimelineLikeTableViewController.swift in Sources */,
|
||||
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
|
||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
||||
|
|
|
@ -99,6 +99,11 @@
|
|||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "DEBUG_BLUR_HASH"
|
||||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
|
|
|
@ -19,6 +19,8 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
|
|||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(style: .grouped)
|
||||
|
||||
dragEnabled = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -33,8 +35,6 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
|
|||
tableView.register(UINib(nibName: "TrendingHashtagTableViewCell", bundle: .main), forCellReuseIdentifier: "trendingTagCell")
|
||||
tableView.rowHeight = 60 // 44 for content + 2 * 8 spacing
|
||||
|
||||
// todo: enable drag
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { (tableView, indexPath, item) in
|
||||
switch item {
|
||||
case let .tag(hashtag):
|
||||
|
@ -75,6 +75,31 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
|
|||
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case let .tag(hashtag) = item else {
|
||||
return nil
|
||||
}
|
||||
return UIContextMenuConfiguration(identifier: nil) {
|
||||
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
|
||||
} actionProvider: { (_) in
|
||||
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.tableView.cellForRow(at: indexPath)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case let .tag(hashtag) = item else {
|
||||
return []
|
||||
}
|
||||
let provider = NSItemProvider(object: hashtag.url as NSURL)
|
||||
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
}
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TrendingHashtagsViewController {
|
||||
|
@ -85,3 +110,11 @@ extension TrendingHashtagsViewController {
|
|||
case tag(Hashtag)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrendingHashtagsViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension TrendingHashtagsViewController: MenuPreviewProvider {
|
||||
var navigationDelegate: TuskerNavigationDelegate? { self }
|
||||
}
|
||||
|
|
|
@ -125,7 +125,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// todo: does this need to be in viewDidLayoutSubviews?
|
||||
// limit the image height to the safe area height, so the image doesn't overlap the top controls
|
||||
// while zoomed all the way out
|
||||
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
|
||||
|
@ -138,7 +137,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
|
||||
centerImage()
|
||||
|
||||
// todo: does this need to be in viewDidLayoutSubviews?
|
||||
let notchedDeviceTopInsets: [CGFloat] = [
|
||||
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
|
||||
48, // iPhone XR, 11
|
||||
|
|
|
@ -178,7 +178,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||
private func createActivityIndicatorHeader() {
|
||||
let header = UITableViewHeaderFooterView()
|
||||
header.translatesAutoresizingMaskIntoConstraints = false
|
||||
header.contentView.backgroundColor = .secondarySystemBackground
|
||||
header.contentView.backgroundColor = .systemGroupedBackground
|
||||
|
||||
activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEntry> {
|
||||
class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<ProfileStatusesViewController.Section, ProfileStatusesViewController.Item> {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
|
@ -43,50 +43,50 @@ class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEnt
|
|||
}
|
||||
|
||||
func updateUI(account: AccountMO) {
|
||||
loadInitial()
|
||||
if isViewLoaded {
|
||||
reloadInitial()
|
||||
}
|
||||
}
|
||||
|
||||
override class func refreshCommandTitle() -> String {
|
||||
return NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title")
|
||||
}
|
||||
|
||||
override func headerSectionsCount() -> Int {
|
||||
return 1
|
||||
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
|
||||
|
||||
cell.delegate = self
|
||||
// todo: dataSource.sectionIdentifier is only available on iOS 15
|
||||
cell.showPinned = dataSource.snapshot().indexOfSection(.pinned) == indexPath.section
|
||||
cell.updateUI(statusID: item.id, state: item.state)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func loadInitial() {
|
||||
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
||||
guard accountID != nil else {
|
||||
completion(.failure(.noClient))
|
||||
return
|
||||
}
|
||||
|
||||
if !loaded {
|
||||
loadPinnedStatuses()
|
||||
}
|
||||
|
||||
super.loadInitial()
|
||||
}
|
||||
|
||||
private func loadPinnedStatuses() {
|
||||
guard kind == .statuses else {
|
||||
return
|
||||
}
|
||||
getPinnedStatuses { (response) in
|
||||
guard case let .success(statuses, _) = response,
|
||||
!statuses.isEmpty else {
|
||||
// todo: error message
|
||||
return
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
let items = statuses.map { ($0.id, StatusState.unknown) }
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
if self.sections.count < 1 {
|
||||
self.sections.append(items)
|
||||
self.tableView.insertSections(IndexSet(integer: 0), with: .none)
|
||||
getStatuses { (response) in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, pagination):
|
||||
self.older = pagination?.older
|
||||
self.newer = pagination?.newer
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
snapshot.appendSections([.statuses])
|
||||
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .statuses)
|
||||
if self.kind == .statuses {
|
||||
self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion)
|
||||
} else {
|
||||
self.sections[0] = items
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -94,64 +94,94 @@ class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEnt
|
|||
}
|
||||
}
|
||||
|
||||
override func loadInitialItems(completion: @escaping ([TimelineEntry]) -> Void) {
|
||||
getStatuses { (response) in
|
||||
guard case let .success(statuses, pagination) = response,
|
||||
!statuses.isEmpty else {
|
||||
// todo: error message
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
self.older = pagination?.older
|
||||
self.newer = pagination?.newer
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
completion(statuses.map { ($0.id, .unknown) })
|
||||
private func loadPinnedStatuses(snapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||
guard kind == .statuses else {
|
||||
completion(.success(snapshot()))
|
||||
return
|
||||
}
|
||||
getPinnedStatuses { (response) in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = snapshot()
|
||||
if snapshot.indexOfSection(.pinned) != nil {
|
||||
snapshot.deleteSections([.pinned])
|
||||
}
|
||||
snapshot.insertSections([.pinned], beforeSection: .statuses)
|
||||
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .pinned)
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func loadOlder(completion: @escaping ([TimelineEntry]) -> Void) {
|
||||
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||
guard let older = older else {
|
||||
completion([])
|
||||
completion(.failure(.noOlder))
|
||||
return
|
||||
}
|
||||
|
||||
getStatuses(for: older) { (response) in
|
||||
guard case let .success(statuses, pagination) = response else {
|
||||
// todo: error message
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
self.older = pagination?.older
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
completion(statuses.map { ($0.id, .unknown) })
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, pagination):
|
||||
guard !statuses.isEmpty else {
|
||||
completion(.failure(.noOlder))
|
||||
return
|
||||
}
|
||||
|
||||
if let older = pagination?.older {
|
||||
self.older = older
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
var snapshot = currentSnapshot()
|
||||
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .statuses)
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override func loadNewer(completion: @escaping ([TimelineEntry]) -> Void) {
|
||||
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||
guard let newer = newer else {
|
||||
completion([])
|
||||
completion(.failure(.noNewer))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
getStatuses(for: newer) { (response) in
|
||||
guard case let .success(statuses, pagination) = response else {
|
||||
// todo: error message
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
if let newer = pagination?.newer {
|
||||
self.newer = newer
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
completion(statuses.map { ($0.id, .unknown) })
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, pagination):
|
||||
guard !statuses.isEmpty else {
|
||||
completion(.failure(.noNewer))
|
||||
return
|
||||
}
|
||||
|
||||
if let newer = pagination?.newer {
|
||||
self.newer = newer
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
var snapshot = currentSnapshot()
|
||||
let items = statuses.map { Item(id: $0.id, state: .unknown) }
|
||||
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
|
||||
snapshot.insertItems(items, beforeItem: first)
|
||||
} else {
|
||||
snapshot.appendItems(items, toSection: .statuses)
|
||||
}
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -178,53 +208,20 @@ class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEnt
|
|||
super.refresh()
|
||||
|
||||
if kind == .statuses {
|
||||
getPinnedStatuses { (response) in
|
||||
guard case let .success(newPinnedStatues, _) = response else {
|
||||
// todo: error message
|
||||
return
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatues) {
|
||||
// if the user refreshes before the initial pinned statuses request completes, self.sections will be empty
|
||||
let oldPinnedStatuses = self.sections.isEmpty ? [] : self.sections[0]
|
||||
let pinnedStatues = newPinnedStatues.map { (status) -> TimelineEntry in
|
||||
let state: StatusState
|
||||
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
|
||||
state = oldState
|
||||
} else {
|
||||
state = .unknown
|
||||
}
|
||||
return (status.id, state)
|
||||
}
|
||||
loadPinnedStatuses(snapshot: dataSource.snapshot) { (result) in
|
||||
switch result {
|
||||
case .failure(_):
|
||||
break
|
||||
|
||||
case let .success(snapshot):
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
if self.sections.count < 1 {
|
||||
self.sections.append(pinnedStatues)
|
||||
self.tableView.insertSections(IndexSet(integer: 0), with: .none)
|
||||
} else {
|
||||
self.sections[0] = pinnedStatues
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
}
|
||||
}
|
||||
self.dataSource.apply(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDatasource
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
|
||||
|
||||
cell.delegate = self
|
||||
cell.showPinned = indexPath.section == 0
|
||||
|
||||
let (id, state) = item(for: indexPath)
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController {
|
||||
|
@ -233,6 +230,17 @@ extension ProfileStatusesViewController {
|
|||
}
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController {
|
||||
enum Section: CaseIterable {
|
||||
case pinned
|
||||
case statuses
|
||||
}
|
||||
struct Item: Hashable {
|
||||
let id: String
|
||||
let state: StatusState
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
@ -245,18 +253,12 @@ extension ProfileStatusesViewController: StatusTableViewCellDelegate {
|
|||
|
||||
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
let ids = indexPaths.map { item(for: $0).id }
|
||||
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
|
||||
prefetchStatuses(with: ids)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
let ids: [String] = indexPaths.compactMap {
|
||||
guard $0.section < sections.count,
|
||||
$0.row < sections[$0.section].count else {
|
||||
return nil
|
||||
}
|
||||
return item(for: $0).id
|
||||
}
|
||||
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
|
||||
cancelPrefetchingStatuses(with: ids)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -211,9 +211,8 @@ class ProfileViewController: UIPageViewController {
|
|||
// Layout and update the table view, otherwise the content jumps around when first scrolling it,
|
||||
// if old was not scrolled all the way to the top
|
||||
new.tableView.layoutIfNeeded()
|
||||
UIView.performWithoutAnimation {
|
||||
new.tableView.performBatchUpdates(nil, completion: nil)
|
||||
}
|
||||
let snapshot = new.dataSource.snapshot()
|
||||
new.dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
completion?(finished)
|
||||
}
|
||||
|
|
|
@ -88,7 +88,10 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||
let sectionsToRemove = contentSections[lastVisibleContentSectionIndex...]
|
||||
snapshot.deleteSections(Array(sectionsToRemove))
|
||||
|
||||
willRemoveItems(sectionsToRemove.flatMap(snapshot.itemIdentifiers(inSection:)))
|
||||
let itemsToRemove = sectionsToRemove.filter {
|
||||
snapshot.indexOfSection($0) != nil
|
||||
}.flatMap(snapshot.itemIdentifiers(inSection:))
|
||||
willRemoveItems(itemsToRemove)
|
||||
} else if lastVisibleContentSectionIndex == contentSections.count - 1 {
|
||||
let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection)
|
||||
|
||||
|
|
|
@ -156,7 +156,6 @@ extension MenuPreviewProvider {
|
|||
}
|
||||
|
||||
let bookmarked = status.bookmarked ?? false
|
||||
let muted = status.muted
|
||||
|
||||
var actionsSection = [
|
||||
createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { [weak self] (_) in
|
||||
|
@ -168,15 +167,6 @@ extension MenuPreviewProvider {
|
|||
}
|
||||
}
|
||||
}),
|
||||
createAction(identifier: "mute", title: muted ? "Unmute" : "Mute", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
|
||||
self.mastodonController?.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
if includeReply {
|
||||
|
@ -186,18 +176,35 @@ extension MenuPreviewProvider {
|
|||
}), at: 0)
|
||||
}
|
||||
|
||||
if mastodonController.account != nil && mastodonController.account.id == status.account.id {
|
||||
let pinned = status.pinned ?? false
|
||||
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
let request = (pinned ? Status.unpin : Status.pin)(status.id)
|
||||
self.mastodonController?.run(request, completion: { [weak self] (response) in
|
||||
if let account = mastodonController.account {
|
||||
// only allow muting conversations that either current user posted or is participating in (technically, is mentioned, since that's the best we can do)
|
||||
if status.account.id == account.id || status.mentions.contains(where: { $0.id == account.id }) {
|
||||
let muted = status.muted
|
||||
actionsSection.append(createAction(identifier: "mute", title: muted ? "Unmute Conversation" : "Mute Conversation", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
|
||||
self.mastodonController?.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
// only allowing pinning user's own statuses
|
||||
if account.id == status.account.id {
|
||||
let pinned = status.pinned ?? false
|
||||
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
let request = (pinned ? Status.unpin : Status.pin)(status.id)
|
||||
self.mastodonController?.run(request, completion: { [weak self] (response) in
|
||||
guard let self = self else { return }
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if status.poll != nil {
|
||||
|
|
|
@ -1,254 +0,0 @@
|
|||
//
|
||||
// TimelineLikeTableViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 11/15/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// A table view controller that manages common functionality between timeline-like UIs.
|
||||
/// For example, this class handles loading new items when the user scrolls to the end,
|
||||
/// refreshing, and pruning offscreen rows automatically.
|
||||
class TimelineLikeTableViewController<Item>: EnhancedTableViewController, RefreshableViewController {
|
||||
|
||||
private(set) var loaded = false
|
||||
|
||||
var sections: [[Item]] = []
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
private var lastLastVisibleRow: IndexPath?
|
||||
|
||||
init() {
|
||||
super.init(style: .plain)
|
||||
|
||||
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: Self.refreshCommandTitle()))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func item(for indexPath: IndexPath) -> Item {
|
||||
return sections[indexPath.section][indexPath.row]
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 140
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
self.refreshControl = UIRefreshControl()
|
||||
self.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
||||
#endif
|
||||
|
||||
if let prefetchSource = self as? UITableViewDataSourcePrefetching {
|
||||
tableView.prefetchDataSource = prefetchSource
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
loadInitial()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
pruneOffscreenRows()
|
||||
}
|
||||
|
||||
func loadInitial() {
|
||||
guard !loaded else { return }
|
||||
// set loaded immediately so we don't trigger another request while the current one is running
|
||||
loaded = true
|
||||
|
||||
loadInitialItems() { (items) in
|
||||
DispatchQueue.main.async {
|
||||
guard items.count > 0 else {
|
||||
// set loaded back to false so the next time the VC appears, we try to load again
|
||||
// todo: this should probably retry automatically
|
||||
self.loaded = false
|
||||
return
|
||||
}
|
||||
|
||||
if self.sections.count < self.headerSectionsCount() {
|
||||
self.sections.insert(contentsOf: Array(repeating: [], count: self.headerSectionsCount() - self.sections.count), at: 0)
|
||||
}
|
||||
|
||||
self.sections.append(items)
|
||||
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadInitialItems() {
|
||||
loaded = false
|
||||
sections = []
|
||||
loadInitial()
|
||||
}
|
||||
|
||||
func cellHeightChanged() {
|
||||
// causes the table view to recalculate the cell heights
|
||||
tableView.beginUpdates()
|
||||
tableView.endUpdates()
|
||||
}
|
||||
|
||||
class func refreshCommandTitle() -> String {
|
||||
return "Refresh"
|
||||
}
|
||||
|
||||
// todo: these three should use Result<[Item], Client.Error> so we can differentiate between failed requests and there actually being no results
|
||||
|
||||
func loadInitialItems(completion: @escaping ([Item]) -> Void) {
|
||||
fatalError("loadInitialItems(completion:) must be implemented by subclasses")
|
||||
}
|
||||
|
||||
func loadOlder(completion: @escaping ([Item]) -> Void) {
|
||||
fatalError("loadOlder(completion:) must be implemented by subclasses")
|
||||
}
|
||||
|
||||
func loadNewer(completion: @escaping ([Item]) -> Void) {
|
||||
fatalError("loadNewer(completion:) must be implemented by subclasses")
|
||||
}
|
||||
|
||||
func willRemoveRows(at indexPaths: [IndexPath]) {
|
||||
}
|
||||
|
||||
func headerSectionsCount() -> Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
private func pruneOffscreenRows() {
|
||||
guard let lastVisibleRow = lastLastVisibleRow,
|
||||
// never remove the last section
|
||||
sections.count - headerSectionsCount() > 1 else {
|
||||
return
|
||||
}
|
||||
let lastSectionIndex = sections.count - 1
|
||||
|
||||
if lastVisibleRow.section < lastSectionIndex {
|
||||
// if there is a section below the last visible one
|
||||
|
||||
let sectionsToRemove = (lastVisibleRow.section + 1)...lastSectionIndex
|
||||
|
||||
let indexPathsToRemove = sectionsToRemove.flatMap { (section) in
|
||||
sections[section].indices.map { (row) in
|
||||
IndexPath(row: row, section: section)
|
||||
}
|
||||
}
|
||||
willRemoveRows(at: indexPathsToRemove)
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
tableView.deleteSections(IndexSet(sectionsToRemove), with: .none)
|
||||
}
|
||||
|
||||
sections.removeSubrange(sectionsToRemove)
|
||||
} else if lastVisibleRow.section == lastSectionIndex {
|
||||
let lastSection = sections.last!
|
||||
let lastRowIndex = lastSection.count - 1
|
||||
|
||||
if lastVisibleRow.row < lastRowIndex - pageSize {
|
||||
// if there are more than pageSize rows in the current section below the last visible one
|
||||
|
||||
let rowIndicesInLastSectionToRemove = (lastVisibleRow.row + pageSize)..<lastSection.count
|
||||
|
||||
let indexPathsToRemove = rowIndicesInLastSectionToRemove.map {
|
||||
IndexPath(row: $0, section: lastSectionIndex)
|
||||
}
|
||||
willRemoveRows(at: indexPathsToRemove)
|
||||
|
||||
sections[lastSectionIndex].removeSubrange(rowIndicesInLastSectionToRemove)
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
tableView.deleteRows(at: indexPathsToRemove, with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return sections.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return sections[section].count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
fatalError("tableView(_:cellForRowAt:) must be implemented by subclasses")
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
// this assumes that indexPathsForVisibleRows is always in order
|
||||
lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last
|
||||
|
||||
if indexPath.section == sections.count - 1,
|
||||
indexPath.row == sections[indexPath.section].count - 1 {
|
||||
loadOlder() { (newItems) in
|
||||
guard newItems.count > 0 else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let newRows = self.sections.last!.count..<(self.sections.last!.count + newItems.count)
|
||||
let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.sections.count - 1) }
|
||||
|
||||
self.sections[self.sections.count - 1].append(contentsOf: newItems)
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: newIndexPaths, with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
||||
}
|
||||
|
||||
// MARK: - RefreshableViewController
|
||||
func refresh() {
|
||||
loadNewer() { (newItems) in
|
||||
DispatchQueue.main.async {
|
||||
self.refreshControl?.endRefreshing()
|
||||
|
||||
guard newItems.count > 0 else { return }
|
||||
|
||||
let firstNonHeaderSection = self.headerSectionsCount()
|
||||
|
||||
self.sections[firstNonHeaderSection].insert(contentsOf: newItems, at: 0)
|
||||
|
||||
let newIndexPaths = (0..<newItems.count).map { IndexPath(row: $0, section: firstNonHeaderSection) }
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: newIndexPaths, with: .none)
|
||||
}
|
||||
|
||||
// maintain the current position in the list (don't scroll to top)
|
||||
self.tableView.scrollToRow(at: IndexPath(row: newItems.count, section: firstNonHeaderSection), at: .top, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineLikeTableViewController: BackgroundableViewController {
|
||||
func sceneDidEnterBackground() {
|
||||
pruneOffscreenRows()
|
||||
}
|
||||
}
|
|
@ -17,6 +17,8 @@ protocol AttachmentViewDelegate: AnyObject {
|
|||
|
||||
class AttachmentView: GIFImageView {
|
||||
|
||||
static let queue = DispatchQueue(label: "Attachment Thumbnail", qos: .userInitiated, attributes: .concurrent)
|
||||
|
||||
weak var delegate: AttachmentViewDelegate?
|
||||
|
||||
var playImageView: UIImageView?
|
||||
|
@ -108,7 +110,7 @@ class AttachmentView: GIFImageView {
|
|||
}
|
||||
|
||||
if let hash = attachment.blurHash {
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
AttachmentView.queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let size: CGSize
|
||||
if let meta = self.attachment.meta,
|
||||
|
@ -191,8 +193,7 @@ class AttachmentView: GIFImageView {
|
|||
})
|
||||
} else {
|
||||
let attachmentURL = self.attachment.url
|
||||
// todo: use a single dispatch queue
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
AttachmentView.queue.async {
|
||||
let asset = AVURLAsset(url: attachmentURL)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
|
@ -237,7 +238,7 @@ class AttachmentView: GIFImageView {
|
|||
func loadGifv() {
|
||||
let attachmentURL = self.attachment.url
|
||||
let asset = AVURLAsset(url: attachmentURL)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
AttachmentView.queue.async {
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
|
||||
|
|
|
@ -15,6 +15,9 @@ class HashtagHistoryView: UIView {
|
|||
|
||||
private let curveRadius: CGFloat = 10
|
||||
|
||||
/// The base background color used for the graph fill.
|
||||
var effectiveBackgroundColor = UIColor.systemBackground
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
|
@ -121,7 +124,7 @@ class HashtagHistoryView: UIView {
|
|||
var tintGreen: CGFloat = 0
|
||||
var tintBlue: CGFloat = 0
|
||||
traitCollection.performAsCurrent {
|
||||
backgroundColor!.getRed(&backgroundRed, green: &backgroundGreen, blue: &backgroundBlue, alpha: nil)
|
||||
effectiveBackgroundColor.getRed(&backgroundRed, green: &backgroundGreen, blue: &backgroundBlue, alpha: nil)
|
||||
tintColor.getRed(&tintRed, green: &tintGreen, blue: &tintBlue, alpha: nil)
|
||||
}
|
||||
let blendedRed = (backgroundRed + tintRed) / 2
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18121" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18091"/>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/>
|
||||
<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>
|
||||
|
@ -39,7 +39,7 @@
|
|||
</stackView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Xrw-2v-ybZ" customClass="HashtagHistoryView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="188" y="11" width="100" height="44"/>
|
||||
<color key="backgroundColor" systemColor="secondarySystemGroupedBackgroundColor"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="44" id="W4C-uw-zWg"/>
|
||||
<constraint firstAttribute="width" constant="100" id="XHb-vd-qNk"/>
|
||||
|
@ -64,9 +64,4 @@
|
|||
<point key="canvasLocation" x="132" y="132"/>
|
||||
</tableViewCell>
|
||||
</objects>
|
||||
<resources>
|
||||
<systemColor name="secondarySystemGroupedBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
|
|
|
@ -163,8 +163,7 @@ class StatusCardView: UIView {
|
|||
|
||||
let imageViewSize = self.imageView.bounds.size
|
||||
|
||||
// todo: merge this code with AttachmentView, use a single DispatchQueue
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
AttachmentView.queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let size: CGSize
|
||||
|
|
Loading…
Reference in New Issue