Compare commits

...

6 Commits

Author SHA1 Message Date
Shadowfacts 26c99a1a35
Improve scroll perform when loading new rows into table views
Instead of reloading the whole table view, only insert the rows for
statuses/notifications that were added.
2020-01-25 11:11:48 -05:00
Shadowfacts 6757031dcb
Hide context menus and swipe actions on instance public timelines 2020-01-25 10:44:31 -05:00
Shadowfacts 7c207efa07
Tweak More swipe action to be in-line with system appearance 2020-01-25 10:44:12 -05:00
Shadowfacts 81256b7a96
Only show local posts on public instance timelines 2020-01-25 10:37:22 -05:00
Shadowfacts 5a6c12c5a7 Fix missing context menu actions on follow notifications for only one person 2020-01-25 10:30:04 -05:00
Shadowfacts d6ae51c02f Improve ImageCache loading
Keep track of the number of requests and only cancel the underlying
URLSessionTask if there are no concrete requsts remaining.

Closes #81
2020-01-25 10:30:04 -05:00
26 changed files with 268 additions and 150 deletions

View File

@ -81,7 +81,7 @@ public class Client {
} }
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: request.baseURL ?? baseURL, resolvingAgainstBaseURL: true) else { return nil } guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path components.path = request.path
components.queryItems = request.queryParameters.queryItems components.queryItems = request.queryParameters.queryItems
guard let url = components.url else { return nil } guard let url = components.url else { return nil }

View File

@ -11,7 +11,6 @@ import Foundation
public enum Timeline { public enum Timeline {
case home case home
case `public`(local: Bool) case `public`(local: Bool)
case instance(instanceURL: URL)
case tag(hashtag: String) case tag(hashtag: String)
case list(id: String) case list(id: String)
case direct case direct
@ -22,7 +21,7 @@ extension Timeline {
switch self { switch self {
case .home: case .home:
return "/api/v1/timelines/home" return "/api/v1/timelines/home"
case .public, .instance(_): case .public:
return "/api/v1/timelines/public" return "/api/v1/timelines/public"
case let .tag(hashtag): case let .tag(hashtag):
return "/api/v1/timelines/tag/\(hashtag)" return "/api/v1/timelines/tag/\(hashtag)"
@ -34,12 +33,7 @@ extension Timeline {
} }
func request(range: RequestRange) -> Request<[Status]> { func request(range: RequestRange) -> Request<[Status]> {
var request: Request<[Status]> var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
if case let .instance(instanceURL) = self {
request = Request<[Status]>(method: .get, baseURL: instanceURL, path: endpoint)
} else {
request = Request<[Status]>(method: .get, path: endpoint)
}
if case .public(true) = self { if case .public(true) = self {
request.queryParameters.append("local" => true) request.queryParameters.append("local" => true)
} }
@ -57,8 +51,6 @@ extension Timeline: Codable {
self = .home self = .home
case "public": case "public":
self = .public(local: try container.decode(Bool.self, forKey: .local)) self = .public(local: try container.decode(Bool.self, forKey: .local))
case "instanceURL":
self = .instance(instanceURL: try container.decode(URL.self, forKey: .instanceURL))
case "tag": case "tag":
self = .tag(hashtag: try container.decode(String.self, forKey: .hashtag)) self = .tag(hashtag: try container.decode(String.self, forKey: .hashtag))
case "list": case "list":
@ -78,9 +70,6 @@ extension Timeline: Codable {
case let .public(local): case let .public(local):
try container.encode("public", forKey: .type) try container.encode("public", forKey: .type)
try container.encode(local, forKey: .local) try container.encode(local, forKey: .local)
case let .instance(instanceURL):
try container.encode("instanceURL", forKey: .type)
try container.encode(instanceURL, forKey: .instanceURL)
case let .tag(hashtag): case let .tag(hashtag):
try container.encode("tag", forKey: .type) try container.encode("tag", forKey: .type)
try container.encode(hashtag, forKey: .hashtag) try container.encode(hashtag, forKey: .hashtag)
@ -95,7 +84,6 @@ extension Timeline: Codable {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case type case type
case local case local
case instanceURL
case hashtag case hashtag
case listID case listID
} }

View File

@ -10,14 +10,12 @@ import Foundation
public struct Request<ResultType: Decodable> { public struct Request<ResultType: Decodable> {
let method: Method let method: Method
let baseURL: URL?
let path: String let path: String
let body: Body let body: Body
var queryParameters: [Parameter] var queryParameters: [Parameter]
init(method: Method, baseURL: URL? = nil, path: String, body: Body = .empty, queryParameters: [Parameter] = []) { init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
self.method = method self.method = method
self.baseURL = baseURL
self.path = path self.path = path
self.body = body self.body = body
self.queryParameters = queryParameters self.queryParameters = queryParameters

View File

@ -12,13 +12,13 @@ import Cache
class ImageCache { class ImageCache {
static let avatars = ImageCache(name: "Avatars", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24)) static let avatars = ImageCache(name: "Avatars", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24))
static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24)) static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2)) static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60)) static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
let cache: Cache<Data> let cache: Cache<Data>
var requests = [URL: Request]() var requests = [URL: RequestGroup]()
init(name: String, memoryExpiry expiry: Expiry) { init(name: String, memoryExpiry expiry: Expiry) {
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry)) let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
@ -36,20 +36,22 @@ class ImageCache {
self.cache = .hybrid(HybridStorage(memoryStorage: memory, diskStorage: disk)) self.cache = .hybrid(HybridStorage(memoryStorage: memory, diskStorage: disk))
} }
func get(_ url: URL, completion: ((Data?) -> Void)?) { func get(_ url: URL, completion: ((Data?) -> Void)?) -> Request? {
let key = url.absoluteString let key = url.absoluteString
if (try? cache.existsObject(forKey: key)) ?? false, if (try? cache.existsObject(forKey: key)) ?? false,
let data = try? cache.object(forKey: key) { let data = try? cache.object(forKey: key) {
completion?(data) completion?(data)
return nil
} else { } else {
if let completion = completion, let request = requests[url] { if let completion = completion, let group = requests[url] {
request.callbacks.append(completion) return group.addCallback(completion)
} else { } else {
let request = Request(url: url, completion: completion) let group = RequestGroup(url: url)
requests[url] = request let request = group.addCallback(completion)
request.run { (data) in group.run { (data) in
try? self.cache.setObject(data, forKey: key) try? self.cache.setObject(data, forKey: key)
} }
return request
} }
} }
} }
@ -58,24 +60,23 @@ class ImageCache {
return try? cache.object(forKey: url.absoluteString) return try? cache.object(forKey: url.absoluteString)
} }
func cancel(_ url: URL) { func cancelWithoutCallback(_ url: URL) {
requests[url]?.cancel() requests[url]?.cancelWithoutCallback()
} }
class Request { class RequestGroup {
let url: URL let url: URL
var task: URLSessionDataTask? private var task: URLSessionDataTask?
var callbacks: [(Data?) -> Void] private var requests = [Request]()
init(url: URL, completion: ((Data?) -> Void)?) { init(url: URL) {
if let completion = completion {
self.callbacks = [completion]
} else {
self.callbacks = []
}
self.url = url self.url = url
} }
deinit {
task?.cancel()
}
func run(cache: @escaping (Data) -> Void) { func run(cache: @escaping (Data) -> Void) {
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
guard error == nil, let data = data else { guard error == nil, let data = data else {
@ -88,13 +89,56 @@ class ImageCache {
task!.resume() task!.resume()
} }
func cancel() { private func updatePriority() {
task?.priority = max(1.0, URLSessionTask.defaultPriority + 0.1 * Float(requests.filter { !$0.cancelled }.count))
}
func addCallback(_ completion: ((Data?) -> Void)?) -> Request {
let request = Request(callback: completion)
requests.append(request)
updatePriority()
return request
}
func cancelWithoutCallback() {
if let request = requests.first(where: { $0.callback == nil && !$0.cancelled }) {
request.cancel()
updatePriority()
}
}
fileprivate func requestCancelled() {
let remaining = requests.filter { !$0.cancelled }.count
if remaining <= 0 {
task?.cancel() task?.cancel()
complete(with: nil) complete(with: nil)
} else {
updatePriority()
}
} }
func complete(with data: Data?) { func complete(with data: Data?) {
callbacks.forEach { $0(data) } requests.filter { !$0.cancelled }.forEach {
if let callback = $0.callback {
callback(data)
}
}
}
}
class Request {
weak var group: RequestGroup?
private(set) var callback: ((Data?) -> Void)?
private(set) var cancelled: Bool = false
init(callback: ((Data?) -> Void)?) {
self.callback = callback
}
func cancel() {
cancelled = true
callback = nil
group?.requestCancelled()
} }
} }

View File

@ -16,8 +16,6 @@ extension Timeline {
return "Home" return "Home"
case let .public(local): case let .public(local):
return local ? "Local" : "Federated" return local ? "Local" : "Federated"
case let .instance(instance):
return instance.host!
case let .tag(hashtag): case let .tag(hashtag):
return "#\(hashtag)" return "#\(hashtag)"
case .list: case .list:
@ -37,8 +35,6 @@ extension Timeline {
} else { } else {
return UIImage(systemName: "globe") return UIImage(systemName: "globe")
} }
case .instance(_):
return UIImage(systemName: "globe")
default: default:
return nil return nil
} }

View File

@ -15,6 +15,8 @@ class AttachmentViewController: UIViewController {
var largeImageVC: LargeImageViewController? var largeImageVC: LargeImageViewController?
var loadingVC: LoadingViewController? var loadingVC: LoadingViewController?
var attachmentRequest: ImageCache.Request?
private var initialControlsVisible: Bool = true private var initialControlsVisible: Bool = true
var controlsVisible: Bool { var controlsVisible: Bool {
get { get {
@ -51,15 +53,25 @@ class AttachmentViewController: UIViewController {
} else { } else {
loadingVC = LoadingViewController() loadingVC = LoadingViewController()
embedChild(loadingVC!) embedChild(loadingVC!)
ImageCache.attachments.get(attachment.url) { [weak self] (data) in attachmentRequest = ImageCache.attachments.get(attachment.url) { [weak self] (data) in
guard let self = self else { return }
self.attachmentRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self?.loadingVC?.removeViewAndController() self.loadingVC?.removeViewAndController()
self?.createLargeImage(data: data!) self.createLargeImage(data: data!)
} }
} }
} }
} }
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent == nil {
attachmentRequest?.cancel()
}
}
func createLargeImage(data: Data) { func createLargeImage(data: Data) {
guard let image = UIImage(data: data) else { return } guard let image = UIImage(data: data) else { return }
largeImageVC = LargeImageViewController(image: image, description: attachment.description, sourceInfo: nil) largeImageVC = LargeImageViewController(image: image, description: attachment.description, sourceInfo: nil)

View File

@ -15,13 +15,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
let mastodonController: MastodonController let mastodonController: MastodonController
var statuses: [(id: String, state: StatusState)] = [] { var statuses: [(id: String, state: StatusState)] = []
didSet {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
var newer: RequestRange? var newer: RequestRange?
var older: RequestRange? var older: RequestRange?
@ -55,6 +49,10 @@ class BookmarksTableViewController: EnhancedTableViewController {
self.statuses.append(contentsOf: statuses.map { ($0.id, .unknown) }) self.statuses.append(contentsOf: statuses.map { ($0.id, .unknown) })
self.newer = pagination?.newer self.newer = pagination?.newer
self.older = pagination?.older self.older = pagination?.older
DispatchQueue.main.async {
self.tableView.reloadData()
}
} }
userActivity = UserActivityManager.bookmarksActivity() userActivity = UserActivityManager.bookmarksActivity()
@ -90,7 +88,14 @@ class BookmarksTableViewController: EnhancedTableViewController {
guard case let .success(newStatuses, pagination) = response else { fatalError() } guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older self.older = pagination?.older
self.mastodonController.cache.addAll(statuses: newStatuses) self.mastodonController.cache.addAll(statuses: newStatuses)
let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map {
IndexPath(row: $0, section: 0)
}
self.statuses.append(contentsOf: newStatuses.map { ($0.id, .unknown) }) self.statuses.append(contentsOf: newStatuses.map { ($0.id, .unknown) })
DispatchQueue.main.async {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
} }
} }
@ -159,9 +164,9 @@ extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue } guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.get(status.account.avatar, completion: nil) _ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image { for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.get(attachment.url, completion: nil) _ = ImageCache.attachments.get(attachment.url, completion: nil)
} }
} }
} }
@ -169,9 +174,9 @@ extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue } guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.cancel(status.account.avatar) ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image { for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancel(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)
} }
} }
} }

View File

@ -170,9 +170,9 @@ extension ConversationTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue } guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.get(status.account.avatar, completion: nil) _ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.get(attachment.url, completion: nil) _ = ImageCache.attachments.get(attachment.url, completion: nil)
} }
} }
} }
@ -180,9 +180,9 @@ extension ConversationTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue } guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.cancel(status.account.avatar) ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.cancel(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)
} }
} }
} }

View File

@ -21,13 +21,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
let excludedTypes: [Pachyderm.Notification.Kind] let excludedTypes: [Pachyderm.Notification.Kind]
let groupTypes = [Notification.Kind.favourite, .reblog, .follow] let groupTypes = [Notification.Kind.favourite, .reblog, .follow]
var groups: [NotificationGroup] = [] { var groups: [NotificationGroup] = []
didSet {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
var newer: RequestRange? var newer: RequestRange?
var older: RequestRange? var older: RequestRange?
@ -73,6 +67,10 @@ class NotificationsTableViewController: EnhancedTableViewController {
self.newer = pagination?.newer self.newer = pagination?.newer
self.older = pagination?.older self.older = pagination?.older
DispatchQueue.main.async {
self.tableView.reloadData()
}
} }
} }
@ -133,6 +131,9 @@ class NotificationsTableViewController: EnhancedTableViewController {
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes) let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
let newIndexPaths = (self.groups.count..<(self.groups.count + groups.count)).map {
IndexPath(row: $0, section: 0)
}
self.groups.append(contentsOf: groups) self.groups.append(contentsOf: groups)
self.mastodonController.cache.addAll(notifications: newNotifications) self.mastodonController.cache.addAll(notifications: newNotifications)
@ -140,6 +141,10 @@ class NotificationsTableViewController: EnhancedTableViewController {
self.mastodonController.cache.addAll(accounts: newNotifications.map { $0.account }) self.mastodonController.cache.addAll(accounts: newNotifications.map { $0.account })
self.older = pagination?.older self.older = pagination?.older
DispatchQueue.main.async {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
} }
} }
} }
@ -216,6 +221,11 @@ class NotificationsTableViewController: EnhancedTableViewController {
} }
DispatchQueue.main.async { DispatchQueue.main.async {
let newIndexPaths = (0..<groups.count).map {
IndexPath(row: $0, section: 0)
}
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
self.refreshControl?.endRefreshing() self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to top) // maintain the current position in the list (don't scroll to top)
@ -240,7 +250,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
for indexPath in indexPaths { for indexPath in indexPaths {
for notificationID in groups[indexPath.row].notificationIDs { for notificationID in groups[indexPath.row].notificationIDs {
guard let notification = mastodonController.cache.notification(for: notificationID) else { continue } guard let notification = mastodonController.cache.notification(for: notificationID) else { continue }
ImageCache.avatars.get(notification.account.avatar, completion: nil) _ = ImageCache.avatars.get(notification.account.avatar, completion: nil)
} }
} }
} }
@ -249,7 +259,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
for indexPath in indexPaths { for indexPath in indexPaths {
for notificationID in groups[indexPath.row].notificationIDs { for notificationID in groups[indexPath.row].notificationIDs {
guard let notification = mastodonController.cache.notification(for: notificationID) else { continue } guard let notification = mastodonController.cache.notification(for: notificationID) else { continue }
ImageCache.avatars.cancel(notification.account.avatar) ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
} }
} }
} }

View File

@ -21,8 +21,8 @@ class MyProfileTableViewController: ProfileTableViewController {
mastodonController.getOwnAccount { (account) in mastodonController.getOwnAccount { (account) in
self.accountID = account.id self.accountID = account.id
ImageCache.avatars.get(account.avatar, completion: { (data) in _ = ImageCache.avatars.get(account.avatar, completion: { [weak self] (data) in
guard let data = data, let image = UIImage(data: data) else { return } guard let self = self, let data = data, let image = UIImage(data: data) else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
let size = CGSize(width: 30, height: 30) let size = CGSize(width: 30, height: 30)
let tabBarImage = UIGraphicsImageRenderer(size: size).image { (_) in let tabBarImage = UIGraphicsImageRenderer(size: size).image { (_) in

View File

@ -294,9 +294,9 @@ extension ProfileTableViewController: UITableViewDataSourcePrefetching {
for indexPath in indexPaths where indexPath.section > 1 { for indexPath in indexPaths where indexPath.section > 1 {
let statusID = timelineSegments[indexPath.section - 2][indexPath.row].id let statusID = timelineSegments[indexPath.section - 2][indexPath.row].id
guard let status = mastodonController.cache.status(for: statusID) else { continue } guard let status = mastodonController.cache.status(for: statusID) else { continue }
ImageCache.avatars.get(status.account.avatar, completion: nil) _ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.get(attachment.url, completion: nil) _ = ImageCache.attachments.get(attachment.url, completion: nil)
} }
} }
} }
@ -305,9 +305,9 @@ extension ProfileTableViewController: UITableViewDataSourcePrefetching {
for indexPath in indexPaths where indexPath.section > 1 { for indexPath in indexPaths where indexPath.section > 1 {
let statusID = timelineSegments[indexPath.section - 2][indexPath.row].id let statusID = timelineSegments[indexPath.section - 2][indexPath.row].id
guard let status = mastodonController.cache.status(for: statusID) else { continue } guard let status = mastodonController.cache.status(for: statusID) else { continue }
ImageCache.avatars.cancel(status.account.avatar) ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.cancel(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)
} }
} }
} }

View File

@ -39,7 +39,10 @@ class InstanceTimelineViewController: TimelineTableViewController {
// the timeline VC only stores a weak reference to it, so we need to store a strong reference to make sure it's not released immediately // the timeline VC only stores a weak reference to it, so we need to store a strong reference to make sure it's not released immediately
instanceMastodonController = MastodonController(instanceURL: url) instanceMastodonController = MastodonController(instanceURL: url)
super.init(for: .instance(instanceURL: url), mastodonController: instanceMastodonController) super.init(for: .public(local: true), mastodonController: instanceMastodonController)
title = url.host!
userActivity = nil // todo: activity for instance-specific timelines
} }
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
@ -74,6 +77,27 @@ class InstanceTimelineViewController: TimelineTableViewController {
// no-op, we don't currently support viewing whole conversations from other instances // no-op, we don't currently support viewing whole conversations from other instances
} }
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
// don't show other screens or actions for other instances
return nil
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
// don't show swipe actions for other instances
return nil
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
// only show more actions for other instances
let more = UIContextualAction(style: .normal, title: "More") { (action, view, completion) in
completion(true)
self.showMoreOptions(forStatus: self.timelineSegments[indexPath.section][indexPath.row].id, sourceView: tableView.cellForRow(at: indexPath))
}
more.image = UIImage(systemName: "ellipsis.circle.fill")
more.backgroundColor = .lightGray
return UISwipeActionsConfiguration(actions: [more])
}
// MARK: - Interaction // MARK: - Interaction
@objc func toggleSaveButtonPressed() { @objc func toggleSaveButtonPressed() {
if SavedDataManager.shared.isSaved(instance: instanceURL, for: parentMastodonController!.accountInfo!) { if SavedDataManager.shared.isSaved(instance: instanceURL, for: parentMastodonController!.accountInfo!) {

View File

@ -14,13 +14,7 @@ class TimelineTableViewController: EnhancedTableViewController {
var timeline: Timeline! var timeline: Timeline!
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
var timelineSegments: [[(id: String, state: StatusState)]] = [] { var timelineSegments: [[(id: String, state: StatusState)]] = []
didSet {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
var newer: RequestRange? var newer: RequestRange?
var older: RequestRange? var older: RequestRange?
@ -69,6 +63,9 @@ class TimelineTableViewController: EnhancedTableViewController {
self.timelineSegments.insert(statuses.map { ($0.id, .unknown) }, at: 0) self.timelineSegments.insert(statuses.map { ($0.id, .unknown) }, at: 0)
self.newer = pagination?.newer self.newer = pagination?.newer
self.older = pagination?.older self.older = pagination?.older
DispatchQueue.main.async {
self.tableView.reloadData()
}
} }
} }
@ -105,7 +102,12 @@ class TimelineTableViewController: EnhancedTableViewController {
guard case let .success(newStatuses, pagination) = response else { fatalError() } guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older self.older = pagination?.older
self.mastodonController.cache.addAll(statuses: newStatuses) self.mastodonController.cache.addAll(statuses: newStatuses)
let newRows = self.timelineSegments.last!.count..<(self.timelineSegments.last!.count + newStatuses.count)
let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.timelineSegments.count - 1) }
self.timelineSegments[self.timelineSegments.count - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) }) self.timelineSegments[self.timelineSegments.count - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
DispatchQueue.main.async {
self.tableView.insertRows(at: newIndexPaths, with: .none)
}
} }
} }
} }
@ -137,6 +139,11 @@ class TimelineTableViewController: EnhancedTableViewController {
} }
DispatchQueue.main.async { DispatchQueue.main.async {
let newIndexPaths = (0..<newStatuses.count).map {
IndexPath(row: $0, section: 0)
}
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
self.refreshControl?.endRefreshing() self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to the top) // maintain the current position in the list (don't scroll to the top)
@ -165,9 +172,9 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statusID(for: indexPath)) else { continue } guard let status = mastodonController.cache.status(for: statusID(for: indexPath)) else { continue }
ImageCache.avatars.get(status.account.avatar, completion: nil) _ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.get(attachment.url, completion: nil) _ = ImageCache.attachments.get(attachment.url, completion: nil)
} }
} }
} }
@ -175,9 +182,9 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statusID(for: indexPath)) else { continue } guard let status = mastodonController.cache.status(for: statusID(for: indexPath)) else { continue }
ImageCache.avatars.cancel(status.account.avatar) ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.cancel(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)
} }
} }
} }

View File

@ -111,9 +111,6 @@ class UserActivityManager {
case .public(local: false): case .public(local: false):
activity.title = NSLocalizedString("Show Federated Timeline", comment: "federated timeline shortcut title") activity.title = NSLocalizedString("Show Federated Timeline", comment: "federated timeline shortcut title")
activity.suggestedInvocationPhrase = NSLocalizedString("Show my federated timeline", comment: "federated timeline invocation phrase") activity.suggestedInvocationPhrase = NSLocalizedString("Show my federated timeline", comment: "federated timeline invocation phrase")
case let .instance(instance):
activity.title = String(format: NSLocalizedString("Show %@", comment: "show instance timeline shortcut title"), instance.host!)
activity.suggestedInvocationPhrase = String(format: NSLocalizedString("Show the instance %@", comment: "instance timeline shortcut invocation phrase"), instance.host!)
case let .tag(hashtag): case let .tag(hashtag):
activity.title = String(format: NSLocalizedString("Show #%@", comment: "show hashtag shortcut title"), hashtag) activity.title = String(format: NSLocalizedString("Show #%@", comment: "show hashtag shortcut title"), hashtag)
activity.suggestedInvocationPhrase = String(format: NSLocalizedString("Show the %@ hashtag", comment: "hashtag shortcut invocation phrase"), hashtag) activity.suggestedInvocationPhrase = String(format: NSLocalizedString("Show the %@ hashtag", comment: "hashtag shortcut invocation phrase"), hashtag)

View File

@ -19,7 +19,7 @@ class AccountTableViewCell: UITableViewCell {
var accountID: String! var accountID: String!
var avatarURL: URL? var avatarRequest: ImageCache.Request?
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -44,9 +44,9 @@ class AccountTableViewCell: UITableViewCell {
fatalError("Missing cached account \(accountID)") fatalError("Missing cached account \(accountID)")
} }
self.avatarURL = account.avatar avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
ImageCache.avatars.get(account.avatar) { (data) in guard let self = self, let data = data, self.accountID == accountID else { return }
guard let data = data, self.avatarURL == account.avatar else { return } self.avatarRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data) self.avatarImageView.image = UIImage(data: data)
} }
@ -57,6 +57,12 @@ class AccountTableViewCell: UITableViewCell {
updateUIForPrefrences() updateUIForPrefrences()
} }
override func prepareForReuse() {
super.prepareForReuse()
avatarRequest?.cancel()
}
} }
extension AccountTableViewCell: SelectableTableViewCell { extension AccountTableViewCell: SelectableTableViewCell {

View File

@ -15,6 +15,8 @@ class LargeAccountDetailView: UIView {
var displayNameLabel = UILabel() var displayNameLabel = UILabel()
var usernameLabel = UILabel() var usernameLabel = UILabel()
var avatarRequest: ImageCache.Request?
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder) super.init(coder: aDecoder)
@ -49,6 +51,10 @@ class LargeAccountDetailView: UIView {
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }
deinit {
avatarRequest?.cancel()
}
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
@ -63,8 +69,9 @@ class LargeAccountDetailView: UIView {
displayNameLabel.text = account.realDisplayName displayNameLabel.text = account.realDisplayName
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
ImageCache.avatars.get(account.avatar) { (data) in avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
guard let data = data else { return } guard let self = self, let data = data else { return }
self.avatarRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data) self.avatarImageView.image = UIImage(data: data)
} }

View File

@ -24,6 +24,8 @@ class AttachmentView: UIImageView, GIFAnimatable {
var attachment: Attachment! var attachment: Attachment!
var index: Int! var index: Int!
private var attachmentRequest: ImageCache.Request?
var gifData: Data? var gifData: Data?
public lazy var animator: Animator? = Animator(withDelegate: self) public lazy var animator: Animator? = Animator(withDelegate: self)
@ -37,6 +39,10 @@ class AttachmentView: UIImageView, GIFAnimatable {
loadAttachment() loadAttachment()
} }
deinit {
attachmentRequest?.cancel()
}
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder) super.init(coder: aDecoder)
commonInit() commonInit()
@ -71,8 +77,9 @@ class AttachmentView: UIImageView, GIFAnimatable {
} }
func loadImage() { func loadImage() {
ImageCache.attachments.get(attachment.url) { [weak self] (data) in attachmentRequest = ImageCache.attachments.get(attachment.url) { [weak self] (data) in
guard let self = self, let data = data else { return } guard let self = self, let data = data else { return }
self.attachmentRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
if self.attachment.url.pathExtension == "gif" { if self.attachment.url.pathExtension == "gif" {
self.animate(withGIFData: data) self.animate(withGIFData: data)
@ -87,12 +94,14 @@ class AttachmentView: UIImageView, GIFAnimatable {
} }
func loadVideo() { func loadVideo() {
let attachmentURL = self.attachment.url
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
let asset = AVURLAsset(url: self.attachment.url) let asset = AVURLAsset(url: attachmentURL)
let generator = AVAssetImageGenerator(asset: asset) let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: CMTime(seconds: 0, preferredTimescale: 1), actualTime: nil) else { return } guard let image = try? generator.copyCGImage(at: CMTime(seconds: 0, preferredTimescale: 1), actualTime: nil) else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
guard self.attachment.url == attachmentURL else { return }
self.image = UIImage(cgImage: image) self.image = UIImage(cgImage: image)
} }
} }

View File

@ -18,10 +18,16 @@ class ComposeStatusReplyView: UIView {
@IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var statusContentTextView: StatusContentTextView! @IBOutlet weak var statusContentTextView: StatusContentTextView!
var avatarRequest: ImageCache.Request?
static func create() -> ComposeStatusReplyView { static func create() -> ComposeStatusReplyView {
return UINib(nibName: "ComposeStatusReplyView", bundle: nil).instantiate(withOwner: nil, options: nil).first as! ComposeStatusReplyView return UINib(nibName: "ComposeStatusReplyView", bundle: nil).instantiate(withOwner: nil, options: nil).first as! ComposeStatusReplyView
} }
deinit {
avatarRequest?.cancel()
}
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -39,8 +45,8 @@ class ComposeStatusReplyView: UIView {
statusContentTextView.overrideMastodonController = mastodonController statusContentTextView.overrideMastodonController = mastodonController
statusContentTextView.statusID = status.id statusContentTextView.statusID = status.id
ImageCache.avatars.get(status.account.avatar) { (data) in avatarRequest = ImageCache.avatars.get(status.account.avatar) { [weak self] (data) in
guard let data = data else { return } guard let self = self, let data = data else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data) self.avatarImageView.image = UIImage(data: data)
} }

View File

@ -49,7 +49,7 @@ class ContentTextView: LinkTextView {
for emoji in emojis { for emoji in emojis {
group.enter() group.enter()
ImageCache.emojis.get(emoji.url) { (data) in _ = ImageCache.emojis.get(emoji.url) { (data) in
defer { group.leave() } defer { group.leave() }
guard let data = data, let image = UIImage(data: data) else { guard let data = data, let image = UIImage(data: data) else {
return return

View File

@ -20,6 +20,7 @@ class InstanceTableViewCell: UITableViewCell {
var selectorInstance: InstanceSelector.Instance? var selectorInstance: InstanceSelector.Instance?
var thumbnailURL: URL? var thumbnailURL: URL?
var thumbnailRequest: ImageCache.Request?
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -59,12 +60,21 @@ class InstanceTableViewCell: UITableViewCell {
private func updateThumbnail(url: URL) { private func updateThumbnail(url: URL) {
thumbnailImageView.image = nil thumbnailImageView.image = nil
thumbnailURL = url thumbnailURL = url
ImageCache.attachments.get(url) { (data) in thumbnailRequest = ImageCache.attachments.get(url) { [weak self] (data) in
guard self.thumbnailURL == url, let data = data, let image = UIImage(data: data) else { return } guard let self = self, self.thumbnailURL == url, let data = data, let image = UIImage(data: data) else { return }
self.thumbnailRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.thumbnailImageView.image = image self.thumbnailImageView.image = image
} }
} }
} }
override func prepareForReuse() {
super.prepareForReuse()
thumbnailRequest?.cancel()
instance = nil
selectorInstance = nil
}
} }

View File

@ -24,7 +24,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
var group: NotificationGroup! var group: NotificationGroup!
var statusID: String! var statusID: String!
var authorAvatarURL: URL? var avatarRequests = [String: ImageCache.Request]()
var updateTimestampWorkItem: DispatchWorkItem? var updateTimestampWorkItem: DispatchWorkItem?
deinit { deinit {
@ -75,9 +75,10 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
imageView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
ImageCache.avatars.get(account.avatar) { (data) in avatarRequests[account.id] = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
guard let data = data, self.group.id == group.id else { return } guard let self = self, let data = data, self.group.id == group.id else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = UIImage(data: data) imageView.image = UIImage(data: data)
} }
} }
@ -149,10 +150,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
if let authorAvatarURL = authorAvatarURL { avatarRequests.values.forEach { $0.cancel() }
ImageCache.avatars.cancel(authorAvatarURL)
}
updateTimestampWorkItem?.cancel() updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil updateTimestampWorkItem = nil
} }

View File

@ -20,6 +20,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
var group: NotificationGroup! var group: NotificationGroup!
var avatarRequests = [String: ImageCache.Request]()
var updateTimestampWorkItem: DispatchWorkItem? var updateTimestampWorkItem: DispatchWorkItem?
deinit { deinit {
@ -55,9 +56,10 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
imageView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
ImageCache.avatars.get(account.avatar) { (data) in avatarRequests[account.id] = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
guard let data = data, self.group.id == group.id else { return } guard let self = self, let data = data, self.group.id == group.id else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = UIImage(data: data) imageView.image = UIImage(data: data)
} }
} }
@ -114,6 +116,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
avatarRequests.values.forEach { $0.cancel() }
updateTimestampWorkItem?.cancel() updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil updateTimestampWorkItem = nil
} }
@ -139,15 +142,19 @@ extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController else { return nil } guard let mastodonController = mastodonController else { return nil }
return (content: {
let accountIDs = self.group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account.id } let accountIDs = self.group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account.id }
return (content: {
if accountIDs.count == 1 { if accountIDs.count == 1 {
return ProfileTableViewController(accountID: accountIDs.first!, mastodonController: mastodonController) return ProfileTableViewController(accountID: accountIDs.first!, mastodonController: mastodonController)
} else { } else {
return AccountListTableViewController(accountIDs: accountIDs, mastodonController: mastodonController) return AccountListTableViewController(accountIDs: accountIDs, mastodonController: mastodonController)
} }
}, actions: { }, actions: {
if accountIDs.count == 1 {
return self.actionsForProfile(accountID: accountIDs.first!, sourceView: self)
} else {
return [] return []
}
}) })
} }
} }

View File

@ -25,6 +25,7 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
var notification: Pachyderm.Notification? var notification: Pachyderm.Notification?
var account: Account! var account: Account!
var avatarRequest: ImageCache.Request?
var updateTimestampWorkItem: DispatchWorkItem? var updateTimestampWorkItem: DispatchWorkItem?
deinit { deinit {
@ -53,8 +54,9 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
func updateUI(account: Account) { func updateUI(account: Account) {
self.account = account self.account = account
actionLabel.text = "Request to follow from \(account.realDisplayName)" actionLabel.text = "Request to follow from \(account.realDisplayName)"
ImageCache.avatars.get(account.avatar) { (data) in avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
guard self.account == account, let data = data, let image = UIImage(data: data) else { return } guard let self = self, self.account == account, let data = data, let image = UIImage(data: data) else { return }
self.avatarRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = image self.avatarImageView.image = image
} }
@ -88,6 +90,7 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
avatarRequest?.cancel()
updateTimestampWorkItem?.cancel() updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil updateTimestampWorkItem = nil
} }

View File

@ -32,8 +32,8 @@ class ProfileHeaderTableViewCell: UITableViewCell {
var accountID: String! var accountID: String!
var avatarURL: URL? var avatarRequest: ImageCache.Request?
var headerURL: URL? var headerRequest: ImageCache.Request?
override func awakeFromNib() { override func awakeFromNib() {
avatarContainerView.layer.masksToBounds = true avatarContainerView.layer.masksToBounds = true
@ -63,19 +63,19 @@ class ProfileHeaderTableViewCell: UITableViewCell {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
avatarImageView.image = nil avatarImageView.image = nil
avatarURL = account.avatar avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
ImageCache.avatars.get(account.avatar) { (data) in guard let self = self, let data = data, self.accountID == accountID else { return }
guard let data = data else { return } self.avatarRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data) self.avatarImageView.image = UIImage(data: data)
self.avatarURL = nil
} }
} }
ImageCache.headers.get(account.header) { (data) in headerImageView.image = nil
guard let data = data else { return } headerRequest = ImageCache.headers.get(account.header) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
self.headerRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.headerImageView.image = UIImage(data: data) self.headerImageView.image = UIImage(data: data)
self.headerURL = nil
} }
} }
@ -131,12 +131,8 @@ class ProfileHeaderTableViewCell: UITableViewCell {
} }
override func prepareForReuse() { override func prepareForReuse() {
if let url = avatarURL { avatarRequest?.cancel()
ImageCache.avatars.cancel(url) headerRequest?.cancel()
}
if let url = headerURL {
ImageCache.headers.cancel(url)
}
} }
@objc func morePressed() { @objc func morePressed() {

View File

@ -64,8 +64,7 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
var showStatusAutomatically = false var showStatusAutomatically = false
var avatarURL: URL? private var avatarRequest: ImageCache.Request?
var attachmentDataTasks: [URLSessionDataTask] = []
private var statusUpdater: Cancellable? private var statusUpdater: Cancellable?
private var accountUpdater: Cancellable? private var accountUpdater: Cancellable?
@ -180,12 +179,10 @@ class BaseStatusTableViewCell: UITableViewCell {
func updateUI(account: Account) { func updateUI(account: Account) {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
avatarImageView.image = nil avatarImageView.image = nil
avatarURL = account.avatar avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
ImageCache.avatars.get(account.avatar) { (data) in guard let self = self, let data = data, self.accountID == account.id else { return }
guard let data = data, self.avatarURL == account.avatar else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data) self.avatarImageView.image = UIImage(data: data)
self.avatarURL = nil
} }
} }
} }
@ -200,9 +197,7 @@ class BaseStatusTableViewCell: UITableViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
if let avatarURL = avatarURL { avatarRequest?.cancel()
ImageCache.avatars.cancel(avatarURL)
}
attachmentsView.attachmentViews.allObjects.forEach { $0.removeFromSuperview() } attachmentsView.attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
showStatusAutomatically = false showStatusAutomatically = false
} }

View File

@ -95,7 +95,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
} }
func updateTimestamp() { func updateTimestamp() {
guard let status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } guard let mastodonController = mastodonController, let status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
timestampLabel.text = status.createdAt.timeAgoString() timestampLabel.text = status.createdAt.timeAgoString()
timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date()) timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date())
@ -236,8 +236,8 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
completion(true) completion(true)
self.delegate?.showMoreOptions(forStatus: self.statusID, sourceView: self) self.delegate?.showMoreOptions(forStatus: self.statusID, sourceView: self)
} }
more.image = UIImage(systemName: "ellipsis") more.image = UIImage(systemName: "ellipsis.circle.fill")
more.backgroundColor = .gray more.backgroundColor = .lightGray
return UISwipeActionsConfiguration(actions: [reply, more]) return UISwipeActionsConfiguration(actions: [reply, more])
} }