You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
440 lines
18 KiB
440 lines
18 KiB
//
|
|
// TimelineTableViewController.swift
|
|
// elpha-ios
|
|
//
|
|
// Created by Dwayne Harris on 10/1/18.
|
|
// Copyright © 2018 Elpha. All rights reserved.
|
|
//
|
|
|
|
import AlamofireImage
|
|
import CoreData
|
|
import MastodonKit
|
|
import UIKit
|
|
|
|
class TimelineTableViewController: UITableViewController {
|
|
let fetchLimit = 50
|
|
let cellHeightsDictionary = NSMutableDictionary()
|
|
|
|
var loading: Bool = false {
|
|
didSet {
|
|
DispatchQueue.main.async {
|
|
if self.loading {
|
|
UIApplication.shared.isNetworkActivityIndicatorVisible = true
|
|
self.refreshControl?.beginRefreshing()
|
|
} else {
|
|
UIApplication.shared.isNetworkActivityIndicatorVisible = false
|
|
self.refreshControl?.endRefreshing()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
tableView.rowHeight = UITableView.automaticDimension
|
|
tableView.estimatedRowHeight = 500
|
|
|
|
let cleanButtonItem = UIBarButtonItem(image: UIImage(named: "Refresh CCW"), style: .plain, target: self, action: #selector(clean))
|
|
let moreButtonItem = UIBarButtonItem(image: UIImage(named: "More"), style: .plain, target: self, action: #selector(more))
|
|
let composeButtonItem = UIBarButtonItem(image: UIImage(named: "Compose"), style: .plain, target: self, action: #selector(compose))
|
|
|
|
navigationItem.rightBarButtonItems = [composeButtonItem, moreButtonItem, cleanButtonItem]
|
|
navigationItem.title = "Home"
|
|
|
|
refreshControl?.addTarget(self, action: #selector(self.fetchTimelineWithDefaultRange), for: .valueChanged)
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
fetchTimelineWithDefaultRange()
|
|
}
|
|
|
|
@objc func clean() {
|
|
guard let session = AuthenticationManager.shared.selectedSession, let timeline = session.selectedTimeline else {
|
|
return
|
|
}
|
|
|
|
timeline.mutableSetValue(forKey: "statuses").removeAllObjects()
|
|
timeline.mutableSetValue(forKey: "boundaries").removeAllObjects()
|
|
|
|
CoreDataManager.shared.saveContext()
|
|
self.tableView.reloadData()
|
|
}
|
|
|
|
@objc func more() {
|
|
|
|
}
|
|
|
|
@objc func compose() {
|
|
let alertController = UIAlertController(title: "Compose", message: "Toot", preferredStyle: .alert)
|
|
alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
|
|
present(alertController, animated: true)
|
|
}
|
|
|
|
func createDefaultTimelines(account: AccountMO) {
|
|
let context = CoreDataManager.shared.getContext()
|
|
let timelineNames = [
|
|
"Home",
|
|
"Local",
|
|
"Federated",
|
|
]
|
|
|
|
timelineNames.forEach { timelineName in
|
|
let timeline = TimelineMO(context: context)
|
|
timeline.name = timelineName
|
|
timeline.account = account
|
|
}
|
|
|
|
CoreDataManager.shared.saveContext()
|
|
}
|
|
|
|
func fetchStatuses(request: Request<[Status]>, forTimeline timeline: TimelineMO, completion: @escaping ([UpsertResult<StatusMO>], Error?) -> Void) {
|
|
guard let client = AuthenticationManager.shared.mkClientForSelectedSession() else {
|
|
completion([], nil)
|
|
return
|
|
}
|
|
|
|
print("Request \(request)")
|
|
|
|
client.run(request) { result in
|
|
switch result {
|
|
case .success(let remoteStatuses, _):
|
|
DispatchQueue.main.async {
|
|
let context = CoreDataManager.shared.getContext()
|
|
let statuses = remoteStatuses.compactMap { status in
|
|
return MastodonDataManager.upsertStatus(status)
|
|
}
|
|
|
|
for (index, statusResult) in statuses.enumerated() {
|
|
if index == 0 {
|
|
let boundary = TimelineBoundaryMO(context: context)
|
|
boundary.statusID = statusResult.model.id!
|
|
boundary.start = true
|
|
boundary.createdAt = Date()
|
|
timeline.mutableSetValue(forKey: "boundaries").add(boundary)
|
|
} else if index == statuses.count {
|
|
let boundary = TimelineBoundaryMO(context: context)
|
|
boundary.statusID = statusResult.model.id!
|
|
boundary.start = false
|
|
boundary.createdAt = Date()
|
|
timeline.mutableSetValue(forKey: "boundaries").add(boundary)
|
|
} else {
|
|
let predicate = NSPredicate(format: "statusID != %@", statusResult.model.id!)
|
|
timeline.mutableSetValue(forKey: "boundaries").filter(using: predicate)
|
|
}
|
|
|
|
statusResult.model.addToTimelines(timeline)
|
|
}
|
|
|
|
// timeline.addToStatuses(NSSet(array: statuses))
|
|
CoreDataManager.shared.saveContext()
|
|
|
|
self.loading = false
|
|
completion(statuses, nil)
|
|
}
|
|
case .failure(let error):
|
|
completion([], error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func fetchTimelineWithDefaultRange() {
|
|
fetchTimeline(withRange: .limit(fetchLimit)) { error in
|
|
guard error == nil else {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func fetchTimeline(withRange requestRange: RequestRange, completion: @escaping (Error?) -> Void) {
|
|
guard let session = AuthenticationManager.shared.selectedSession, let account = session.account else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
if session.selectedTimeline == nil {
|
|
if let timelines = account.timelines, timelines.count == 0 {
|
|
createDefaultTimelines(account: account)
|
|
}
|
|
|
|
let request = NSFetchRequest<TimelineMO>(entityName: "Timeline")
|
|
request.predicate = NSPredicate(format: "name == %@", "Home")
|
|
|
|
do {
|
|
let results = try CoreDataManager.shared.getContext().fetch(request)
|
|
session.selectedTimeline = results.first
|
|
CoreDataManager.shared.saveContext()
|
|
} catch {
|
|
print("\(error)")
|
|
}
|
|
}
|
|
|
|
guard let selectedTimeline = session.selectedTimeline else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
loading = true
|
|
|
|
var request: Request<[Status]>
|
|
|
|
switch selectedTimeline.name {
|
|
case "Home":
|
|
request = Timelines.home(range: requestRange)
|
|
case "Local":
|
|
request = Timelines.public(local: true, range: requestRange)
|
|
case "Federated":
|
|
request = Timelines.public(local: false, range: requestRange)
|
|
case let tag:
|
|
request = Timelines.tag(tag!, range: requestRange)
|
|
}
|
|
|
|
fetchStatuses(request: request, forTimeline: selectedTimeline) { statuses, error in
|
|
guard error == nil else {
|
|
completion(error)
|
|
return
|
|
}
|
|
|
|
let newStatuses = statuses.filter { $0.new }
|
|
if newStatuses.count > -1 {
|
|
DispatchQueue.main.async {
|
|
if let navigationController = self.navigationController as? TimelinesNavigationController,
|
|
let newStatusesView = navigationController.newStatusesView {
|
|
newStatusesView.setCount(newStatuses.count)
|
|
newStatusesView.isHidden = false
|
|
self.view.layoutIfNeeded()
|
|
|
|
UIView.animate(withDuration: 2.0, animations: { () -> Void in
|
|
navigationController.bottomLayoutConstraint?.constant = -20
|
|
self.view.layoutIfNeeded()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
completion(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TimelineTableViewController {
|
|
func timelineRequest() -> NSFetchRequest<TimelineMO>? {
|
|
guard let session = AuthenticationManager.shared.selectedSession, let selectedTimeline = session.selectedTimeline else {
|
|
return nil
|
|
}
|
|
|
|
let request = NSFetchRequest<TimelineMO>(entityName: "Timeline")
|
|
request.predicate = NSPredicate(format: "name == %@", selectedTimeline.name!)
|
|
|
|
return request
|
|
}
|
|
|
|
func timelineStatuses() -> [StatusMO]? {
|
|
guard let request = timelineRequest() else {
|
|
return []
|
|
}
|
|
|
|
do {
|
|
let response = try CoreDataManager.shared.getContext().fetch(request)
|
|
guard let timeline = response.first, let statuses = timeline.statuses else {
|
|
return []
|
|
}
|
|
|
|
let sortDescriptor = NSSortDescriptor(key: "createdAt", ascending: false)
|
|
return statuses.sortedArray(using: [sortDescriptor]) as? [StatusMO]
|
|
} catch {
|
|
print("\(error)")
|
|
return []
|
|
}
|
|
}
|
|
|
|
func timelineStatusesCount() -> Int {
|
|
guard let statuses = timelineStatuses() else {
|
|
return 0
|
|
}
|
|
|
|
return statuses.count
|
|
}
|
|
|
|
func timelineBoundaries() -> NSSet {
|
|
guard let session = AuthenticationManager.shared.selectedSession,
|
|
let timeline = session.selectedTimeline,
|
|
let boundaries = timeline.boundaries else {
|
|
return NSSet()
|
|
}
|
|
|
|
return boundaries
|
|
}
|
|
|
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
|
return 1
|
|
}
|
|
}
|
|
|
|
extension TimelineTableViewController {
|
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
return timelineStatusesCount()
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
|
cellHeightsDictionary.setValue(cell.frame.size.height, forKey: String(indexPath.row))
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
if let height = cellHeightsDictionary.object(forKey: String(indexPath.row)) as? CGFloat {
|
|
return height
|
|
}
|
|
|
|
return UITableView.automaticDimension
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: "TimelineTableViewCell", for: indexPath) as? TimelineTableViewCell else {
|
|
fatalError("Unable to find reusable cell")
|
|
}
|
|
|
|
cell.statusView.topDividerView.isHidden = false
|
|
cell.statusView.topLoadMoreView.isHidden = true
|
|
cell.statusView.boostView.isHidden = true
|
|
cell.statusView.replyView.isHidden = true
|
|
cell.statusView.bottomLoadMoreView.isHidden = true
|
|
cell.statusView.bottomDividerView.isHidden = false
|
|
|
|
cell.statusView.attachmentsView.backgroundColor = UIColor.white
|
|
cell.statusView.attachmentsHeightConstraint.constant = cell.frame.width
|
|
cell.statusView.attachmentsView.isHidden = true
|
|
|
|
if let statuses = timelineStatuses() {
|
|
let status = statuses[indexPath.row]
|
|
let boundaries = timelineBoundaries()
|
|
|
|
let avatarFilter = AspectScaledToFillSizeWithRoundedCornersFilter(
|
|
size: CGSize(width: 40.0, height: 40.0),
|
|
radius: 20.0,
|
|
divideRadiusByImageScale: true
|
|
)
|
|
|
|
if indexPath.row != 0 {
|
|
if let boundary = boundaries.filtered(using: NSPredicate(format: "statusID = %@", status.id!)).first as? TimelineBoundaryMO {
|
|
if boundary.start {
|
|
let previousStatus = statuses[indexPath.row - 1]
|
|
if let previousBoundary = boundaries.filtered(using: NSPredicate(format: "statusID = %@", previousStatus.id!)).first as? TimelineBoundaryMO {
|
|
if !previousBoundary.start {
|
|
cell.statusView.topDividerView.isHidden = true
|
|
cell.statusView.topLoadMoreView.isHidden = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
cell.statusView.topDividerView.isHidden = true
|
|
}
|
|
|
|
if indexPath.row < statuses.count - 1 {
|
|
if let boundary = boundaries.filtered(using: NSPredicate(format: "statusID = %@", status.id!)).first as? TimelineBoundaryMO {
|
|
if !boundary.start {
|
|
let nextStatus = statuses[indexPath.row + 1]
|
|
if let nextBoundary = boundaries.filtered(using: NSPredicate(format: "statusID = %@", nextStatus.id!)).first as? TimelineBoundaryMO {
|
|
if nextBoundary.start {
|
|
cell.statusView.bottomDividerView.isHidden = true
|
|
cell.statusView.bottomLoadMoreView.isHidden = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func setStatusContent(_ status: StatusMO) {
|
|
if let account = status.account {
|
|
cell.statusView.avatarImageView.af_setImage(withURL: account.avatarURL!, filter: avatarFilter)
|
|
cell.statusView.displayNameLabel.text = account.displayName
|
|
cell.statusView.usernameLabel.text = account.acct
|
|
}
|
|
|
|
if let attachments = status.attachments, attachments.count > 0 {
|
|
cell.statusView.attachmentsView.isHidden = false
|
|
AttachmentsManager.setupAttachmentsView(cell.statusView.attachmentsView, withAttachments: attachments)
|
|
}
|
|
|
|
if let content = status.content {
|
|
do {
|
|
let styledContent = "<style>html * { font-size: 15px; color: #170c49; font-family: -apple-system } p { margin: 0; padding: 0 }</style> \(content)"
|
|
let attributedText = try NSAttributedString(
|
|
data: styledContent.data(using: String.Encoding.unicode, allowLossyConversion: true)!,
|
|
options: [.documentType: NSAttributedString.DocumentType.html],
|
|
documentAttributes: nil
|
|
)
|
|
|
|
cell.statusView.contentLabel.attributedText = attributedText
|
|
} catch {
|
|
print("\(error)")
|
|
}
|
|
}
|
|
|
|
cell.statusView.timestampLabel.text = status.createdAt!.timeAgo()
|
|
cell.statusView.repliesLabel.text = "0"
|
|
cell.statusView.boostsLabel.text = NumberFormatter.localizedString(from: NSNumber(value: status.reblogsCount), number: .decimal)
|
|
cell.statusView.favoritesLabel.text = NumberFormatter.localizedString(from: NSNumber(value: status.favouritesCount), number: .decimal)
|
|
|
|
if status.reblogged {
|
|
cell.statusView.boostsImageView.image = UIImage(named: "Boost Bold")
|
|
} else {
|
|
cell.statusView.boostsImageView.image = UIImage(named: "Boost Regular")
|
|
}
|
|
|
|
if status.favourited {
|
|
cell.statusView.favoritesImageView.image = UIImage(named: "Star Filled")
|
|
} else {
|
|
cell.statusView.favoritesImageView.image = UIImage(named: "Star Regular")
|
|
}
|
|
}
|
|
|
|
if let reblog = status.reblog {
|
|
cell.statusView.boostView.isHidden = false
|
|
|
|
if let account = status.account {
|
|
cell.statusView.boostAvatarImageView.af_setImage(withURL: account.avatarURL!, filter: avatarFilter)
|
|
cell.statusView.boostDisplayNameLabel.text = account.displayName
|
|
cell.statusView.boostUsernameLabel.text = account.acct
|
|
}
|
|
|
|
setStatusContent(reblog)
|
|
} else {
|
|
if let account = status.account {
|
|
cell.statusView.avatarImageView.af_setImage(withURL: account.avatarURL!, filter: avatarFilter)
|
|
cell.statusView.displayNameLabel.text = account.displayName
|
|
cell.statusView.usernameLabel.text = account.acct
|
|
}
|
|
|
|
setStatusContent(status)
|
|
}
|
|
|
|
if let replyAccountID = status.inReplyToAccountID {
|
|
if let replyAccount = MastodonDataManager.getAccountByID(replyAccountID) {
|
|
cell.statusView.replyView.isHidden = false
|
|
cell.statusView.replyAvatarImageView.af_setImage(withURL: replyAccount.avatarURL!, filter: avatarFilter)
|
|
cell.statusView.replyDisplayNameLabel.text = replyAccount.displayName
|
|
cell.statusView.replyUsernameLabel.text = replyAccount.acct
|
|
}
|
|
}
|
|
|
|
|
|
if indexPath.row == statuses.count - 1 && !loading {
|
|
fetchTimeline(withRange: .max(id: status.id!, limit: fetchLimit)) { error in
|
|
guard error == nil else {
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
self.tableView.reloadData()
|
|
}
|
|
}
|
|
}
|
|
|
|
return cell
|
|
}
|
|
|
|
return cell
|
|
}
|
|
}
|