Compare commits

..

No commits in common. "2b22180191a2217b5a53bae9bde42f5f4468a3e0" and "ca5ac8b826701be283bcb8cddbfcaf2808cffd66" have entirely different histories.

15 changed files with 422 additions and 210 deletions

View File

@ -12,7 +12,6 @@ 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 {

View File

@ -154,6 +154,7 @@
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 */; };
@ -558,6 +559,7 @@
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>"; };
@ -1521,6 +1523,7 @@
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
@ -2077,6 +2080,7 @@
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 */,

View File

@ -99,11 +99,6 @@
value = "1"
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "DEBUG_BLUR_HASH"
value = "1"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction

View File

@ -19,8 +19,6 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
self.mastodonController = mastodonController
super.init(style: .grouped)
dragEnabled = true
}
required init?(coder: NSCoder) {
@ -35,6 +33,8 @@ 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,31 +75,6 @@ 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 {
@ -110,11 +85,3 @@ extension TrendingHashtagsViewController {
case tag(Hashtag)
}
}
extension TrendingHashtagsViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension TrendingHashtagsViewController: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { self }
}

View File

@ -125,6 +125,7 @@ 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
@ -137,6 +138,7 @@ 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

View File

@ -178,7 +178,7 @@ class InstanceSelectorTableViewController: UITableViewController {
private func createActivityIndicatorHeader() {
let header = UITableViewHeaderFooterView()
header.translatesAutoresizingMaskIntoConstraints = false
header.contentView.backgroundColor = .systemGroupedBackground
header.contentView.backgroundColor = .secondarySystemBackground
activityIndicator = UIActivityIndicatorView(style: .large)
activityIndicator.translatesAutoresizingMaskIntoConstraints = false

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<ProfileStatusesViewController.Section, ProfileStatusesViewController.Item> {
class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEntry> {
weak var mastodonController: MastodonController!
@ -43,145 +43,115 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
}
func updateUI(account: AccountMO) {
if isViewLoaded {
reloadInitial()
}
loadInitial()
}
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title")
}
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 headerSectionsCount() -> Int {
return 1
}
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
override func loadInitial() {
guard accountID != nil else {
completion(.failure(.noClient))
return
}
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 {
completion(.success(snapshot))
}
}
}
}
if !loaded {
loadPinnedStatuses()
}
super.loadInitial()
}
private func loadPinnedStatuses(snapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
private func loadPinnedStatuses() {
guard kind == .statuses else {
completion(.success(snapshot()))
return
}
getPinnedStatuses { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
guard case let .success(statuses, _) = response,
!statuses.isEmpty else {
// todo: error message
return
}
case let .success(statuses, _):
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = snapshot()
if snapshot.indexOfSection(.pinned) != nil {
snapshot.deleteSections([.pinned])
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)
} else {
self.sections[0] = items
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
}
snapshot.insertSections([.pinned], beforeSection: .statuses)
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .pinned)
completion(.success(snapshot))
}
}
}
}
}
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
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) })
}
}
}
override func loadOlder(completion: @escaping ([TimelineEntry]) -> Void) {
guard let older = older else {
completion(.failure(.noOlder))
completion([])
return
}
getStatuses(for: older) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
guard case let .success(statuses, pagination) = response else {
// todo: error message
completion([])
return
}
case let .success(statuses, pagination):
guard !statuses.isEmpty else {
completion(.failure(.noOlder))
return
}
self.older = pagination?.older
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))
}
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
}
}
}
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
override func loadNewer(completion: @escaping ([TimelineEntry]) -> Void) {
guard let newer = newer else {
completion(.failure(.noNewer))
completion([])
return
}
getStatuses(for: newer) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
guard case let .success(statuses, pagination) = response else {
// todo: error message
completion([])
return
}
case let .success(statuses, pagination):
guard !statuses.isEmpty else {
completion(.failure(.noNewer))
return
}
if let newer = pagination?.newer {
self.newer = newer
}
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))
}
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
}
}
}
@ -208,20 +178,53 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
super.refresh()
if kind == .statuses {
loadPinnedStatuses(snapshot: dataSource.snapshot) { (result) in
switch result {
case .failure(_):
break
getPinnedStatuses { (response) in
guard case let .success(newPinnedStatues, _) = response else {
// todo: error message
return
}
case let .success(snapshot):
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)
}
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
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)
}
}
}
}
}
}
}
// 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 {
@ -230,17 +233,6 @@ 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 }
}
@ -253,12 +245,18 @@ extension ProfileStatusesViewController: StatusTableViewCellDelegate {
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
let ids = indexPaths.map { item(for: $0).id }
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
let ids: [String] = indexPaths.compactMap {
guard $0.section < sections.count,
$0.row < sections[$0.section].count else {
return nil
}
return item(for: $0).id
}
cancelPrefetchingStatuses(with: ids)
}
}

View File

@ -211,8 +211,9 @@ 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()
let snapshot = new.dataSource.snapshot()
new.dataSource.apply(snapshot, animatingDifferences: false)
UIView.performWithoutAnimation {
new.tableView.performBatchUpdates(nil, completion: nil)
}
completion?(finished)
}

View File

@ -88,10 +88,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
let sectionsToRemove = contentSections[lastVisibleContentSectionIndex...]
snapshot.deleteSections(Array(sectionsToRemove))
let itemsToRemove = sectionsToRemove.filter {
snapshot.indexOfSection($0) != nil
}.flatMap(snapshot.itemIdentifiers(inSection:))
willRemoveItems(itemsToRemove)
willRemoveItems(sectionsToRemove.flatMap(snapshot.itemIdentifiers(inSection:)))
} else if lastVisibleContentSectionIndex == contentSections.count - 1 {
let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection)

View File

@ -156,6 +156,7 @@ 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
@ -167,6 +168,15 @@ 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 {
@ -176,35 +186,18 @@ extension MenuPreviewProvider {
}), at: 0)
}
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
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
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 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 {

View File

@ -0,0 +1,254 @@
//
// 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()
}
}

View File

@ -17,8 +17,6 @@ protocol AttachmentViewDelegate: AnyObject {
class AttachmentView: GIFImageView {
static let queue = DispatchQueue(label: "Attachment Thumbnail", qos: .userInitiated, attributes: .concurrent)
weak var delegate: AttachmentViewDelegate?
var playImageView: UIImageView?
@ -110,7 +108,7 @@ class AttachmentView: GIFImageView {
}
if let hash = attachment.blurHash {
AttachmentView.queue.async { [weak self] in
DispatchQueue.global(qos: .default).async { [weak self] in
guard let self = self else { return }
let size: CGSize
if let meta = self.attachment.meta,
@ -193,7 +191,8 @@ class AttachmentView: GIFImageView {
})
} else {
let attachmentURL = self.attachment.url
AttachmentView.queue.async {
// todo: use a single dispatch queue
DispatchQueue.global(qos: .userInitiated).async {
let asset = AVURLAsset(url: attachmentURL)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
@ -238,7 +237,7 @@ class AttachmentView: GIFImageView {
func loadGifv() {
let attachmentURL = self.attachment.url
let asset = AVURLAsset(url: attachmentURL)
AttachmentView.queue.async {
DispatchQueue.global(qos: .userInitiated).async {
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }

View File

@ -15,9 +15,6 @@ 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()
@ -124,7 +121,7 @@ class HashtagHistoryView: UIView {
var tintGreen: CGFloat = 0
var tintBlue: CGFloat = 0
traitCollection.performAsCurrent {
effectiveBackgroundColor.getRed(&backgroundRed, green: &backgroundGreen, blue: &backgroundBlue, alpha: nil)
backgroundColor!.getRed(&backgroundRed, green: &backgroundGreen, blue: &backgroundBlue, alpha: nil)
tintColor.getRed(&tintRed, green: &tintGreen, blue: &tintBlue, alpha: nil)
}
let blendedRed = (backgroundRed + tintRed) / 2

View File

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<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">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18091"/>
<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" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="backgroundColor" systemColor="secondarySystemGroupedBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" constant="44" id="W4C-uw-zWg"/>
<constraint firstAttribute="width" constant="100" id="XHb-vd-qNk"/>
@ -64,4 +64,9 @@
<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>

View File

@ -163,7 +163,8 @@ class StatusCardView: UIView {
let imageViewSize = self.imageView.bounds.size
AttachmentView.queue.async { [weak self] in
// todo: merge this code with AttachmentView, use a single DispatchQueue
DispatchQueue.global(qos: .default).async { [weak self] in
guard let self = self else { return }
let size: CGSize