Compare commits

...

8 Commits

15 changed files with 212 additions and 424 deletions

View File

@ -12,6 +12,7 @@ public class Mention: Codable {
public let url: URL public let url: URL
public let username: String public let username: String
public let acct: String public let acct: String
/// The instance-local ID of the user being mentioned.
public let id: String public let id: String
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {

View File

@ -154,7 +154,6 @@
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; }; D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.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 */; }; D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */; };
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; }; D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
@ -1523,7 +1521,6 @@
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */, D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */, D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */, D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */, D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */, D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */, D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
@ -2080,7 +2077,6 @@
D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */, D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */, D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */, D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
D65234C9256189D0001AF9CF /* TimelineLikeTableViewController.swift in Sources */,
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */, D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */, D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,

View File

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

View File

@ -19,6 +19,8 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(style: .grouped) super.init(style: .grouped)
dragEnabled = true
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -33,8 +35,6 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
tableView.register(UINib(nibName: "TrendingHashtagTableViewCell", bundle: .main), forCellReuseIdentifier: "trendingTagCell") tableView.register(UINib(nibName: "TrendingHashtagTableViewCell", bundle: .main), forCellReuseIdentifier: "trendingTagCell")
tableView.rowHeight = 60 // 44 for content + 2 * 8 spacing tableView.rowHeight = 60 // 44 for content + 2 * 8 spacing
// todo: enable drag
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { (tableView, indexPath, item) in dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { (tableView, indexPath, item) in
switch item { switch item {
case let .tag(hashtag): case let .tag(hashtag):
@ -75,6 +75,31 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) 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 { extension TrendingHashtagsViewController {
@ -85,3 +110,11 @@ extension TrendingHashtagsViewController {
case tag(Hashtag) case tag(Hashtag)
} }
} }
extension TrendingHashtagsViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension TrendingHashtagsViewController: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { self }
}

View File

@ -125,7 +125,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.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 // limit the image height to the safe area height, so the image doesn't overlap the top controls
// while zoomed all the way out // while zoomed all the way out
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
@ -138,7 +137,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
centerImage() centerImage()
// todo: does this need to be in viewDidLayoutSubviews?
let notchedDeviceTopInsets: [CGFloat] = [ let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max 44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11 48, // iPhone XR, 11

View File

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

View File

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

View File

@ -211,9 +211,8 @@ class ProfileViewController: UIPageViewController {
// Layout and update the table view, otherwise the content jumps around when first scrolling it, // 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 // if old was not scrolled all the way to the top
new.tableView.layoutIfNeeded() new.tableView.layoutIfNeeded()
UIView.performWithoutAnimation { let snapshot = new.dataSource.snapshot()
new.tableView.performBatchUpdates(nil, completion: nil) new.dataSource.apply(snapshot, animatingDifferences: false)
}
completion?(finished) completion?(finished)
} }

View File

@ -88,7 +88,10 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
let sectionsToRemove = contentSections[lastVisibleContentSectionIndex...] let sectionsToRemove = contentSections[lastVisibleContentSectionIndex...]
snapshot.deleteSections(Array(sectionsToRemove)) 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 { } else if lastVisibleContentSectionIndex == contentSections.count - 1 {
let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection) let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection)

View File

@ -156,7 +156,6 @@ extension MenuPreviewProvider {
} }
let bookmarked = status.bookmarked ?? false let bookmarked = status.bookmarked ?? false
let muted = status.muted
var actionsSection = [ var actionsSection = [
createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { [weak self] (_) in 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 { if includeReply {
@ -186,18 +176,35 @@ extension MenuPreviewProvider {
}), at: 0) }), at: 0)
} }
if mastodonController.account != nil && mastodonController.account.id == status.account.id { if let account = mastodonController.account {
let pinned = status.pinned ?? false // only allow muting conversations that either current user posted or is participating in (technically, is mentioned, since that's the best we can do)
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in if status.account.id == account.id || status.mentions.contains(where: { $0.id == account.id }) {
guard let self = self else { return } let muted = status.muted
let request = (pinned ? Status.unpin : Status.pin)(status.id) actionsSection.append(createAction(identifier: "mute", title: muted ? "Unmute Conversation" : "Mute Conversation", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
self.mastodonController?.run(request, completion: { [weak self] (response) in
guard let self = self else { return } guard let self = self else { return }
if case let .success(status, _) = response { let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) 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 { if status.poll != nil {

View File

@ -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()
}
}

View File

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

View File

@ -15,6 +15,9 @@ class HashtagHistoryView: UIView {
private let curveRadius: CGFloat = 10 private let curveRadius: CGFloat = 10
/// The base background color used for the graph fill.
var effectiveBackgroundColor = UIColor.systemBackground
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
@ -121,7 +124,7 @@ class HashtagHistoryView: UIView {
var tintGreen: CGFloat = 0 var tintGreen: CGFloat = 0
var tintBlue: CGFloat = 0 var tintBlue: CGFloat = 0
traitCollection.performAsCurrent { 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) tintColor.getRed(&tintRed, green: &tintGreen, blue: &tintBlue, alpha: nil)
} }
let blendedRed = (backgroundRed + tintRed) / 2 let blendedRed = (backgroundRed + tintRed) / 2

View File

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <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="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"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<objects> <objects>
@ -39,7 +39,7 @@
</stackView> </stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Xrw-2v-ybZ" customClass="HashtagHistoryView" customModule="Tusker" customModuleProvider="target"> <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"/> <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> <constraints>
<constraint firstAttribute="height" constant="44" id="W4C-uw-zWg"/> <constraint firstAttribute="height" constant="44" id="W4C-uw-zWg"/>
<constraint firstAttribute="width" constant="100" id="XHb-vd-qNk"/> <constraint firstAttribute="width" constant="100" id="XHb-vd-qNk"/>
@ -64,9 +64,4 @@
<point key="canvasLocation" x="132" y="132"/> <point key="canvasLocation" x="132" y="132"/>
</tableViewCell> </tableViewCell>
</objects> </objects>
<resources>
<systemColor name="secondarySystemGroupedBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document> </document>

View File

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