Start converting UI to use CoreData backed objects instead of API

objects directly
This commit is contained in:
Shadowfacts 2020-04-12 12:54:27 -04:00
parent a0e95d4577
commit 2c8ba878b7
Signed by untrusted user: shadowfacts
GPG Key ID: 94A5AB95422746E5
21 changed files with 146 additions and 101 deletions

View File

@ -58,7 +58,7 @@ extension AccountMO {
}
self.acct = account.acct
self.avatar = account.avatar
self.avatar = account.avatarStatic // we don't animate avatars
self.bot = account.bot ?? false
self.createdAt = account.createdAt
self.displayName = account.displayName
@ -66,7 +66,7 @@ extension AccountMO {
self.fields = account.fields ?? []
self.followersCount = account.followersCount
self.followingCount = account.followingCount
self.header = account.header
self.header = account.headerStatic // we don't animate headers
self.id = account.id
self.locked = account.locked
self.moved = account.moved ?? false

View File

@ -37,19 +37,33 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
}
private func upsert(status: Status) {
if let statusMO = self.status(for: status.id, in: self.backgroundContext) {
statusMO.updateFrom(apiStatus: status, container: self)
} else {
_ = StatusMO(apiStatus: status, container: self, context: self.backgroundContext)
}
}
func addOrUpdate(status: Status, save: Bool = true) {
backgroundContext.perform {
if let statusMO = self.status(for: status.id, in: self.backgroundContext) {
statusMO.updateFrom(apiStatus: status, container: self)
} else {
_ = StatusMO(apiStatus: status, container: self, context: self.backgroundContext)
}
self.upsert(status: status)
if save, self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
}
}
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform {
statuses.forEach(self.upsert(status:))
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
}
}
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
let context = context ?? viewContext
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
@ -62,17 +76,31 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
}
private func upsert(account: Account) {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self)
} else {
_ = AccountMO(apiAccount: account, container: self, context: self.backgroundContext)
}
}
func addOrUpdate(account: Account, save: Bool = true) {
backgroundContext.perform {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self)
} else {
_ = AccountMO(apiAccount: account, container: self, context: self.backgroundContext)
}
self.upsert(account: account)
if save, self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
}
}
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform {
accounts.forEach(self.upsert(account:))
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
}
}
}

View File

@ -36,6 +36,7 @@ public final class StatusMO: NSManagedObject {
@NSManaged public var reblogged: Bool
@NSManaged public var reblogsCount: Int
@NSManaged public var sensitive: Bool
@NSManaged public var spoilerText: String
@NSManaged public var uri: String // todo: are both uri and url necessary?
@NSManaged public var url: URL?
@NSManaged private var visibilityString: String
@ -95,6 +96,7 @@ extension StatusMO {
self.reblogged = status.reblogged ?? false
self.reblogsCount = status.reblogsCount
self.sensitive = status.sensitive
self.spoilerText = status.spoilerText
self.uri = status.uri
self.visibility = status.visibility
self.account = container.account(for: status.account.id, in: context) ?? AccountMO(apiAccount: status.account, container: container, context: context)

View File

@ -44,6 +44,7 @@
<attribute name="reblogged" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" attributeType="String"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/>
@ -57,6 +58,6 @@
</entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="313"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="388"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="403"/>
</elements>
</model>

View File

@ -9,7 +9,7 @@
import Foundation
import Pachyderm
extension Account {
extension AccountMO {
var displayOrUserName: String {
if displayName.isEmpty {
@ -31,7 +31,7 @@ extension Account {
private func stripCustomEmoji(from string: String) -> String {
let range = NSRange(location: 0, length: string.utf16.count)
return Account.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
return AccountMO.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
}
}

View File

@ -11,6 +11,7 @@ import Foundation
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
// todo: invalidate cache on underlying data change using KVO?
@propertyWrapper
struct LazilyDecoding<Enclosing, Value: Codable> {

View File

@ -64,12 +64,7 @@ class MastodonCache {
func addAll(statuses: [Status]) {
statuses.forEach(add)
if let container = mastodonController?.persistentContainer {
statuses.forEach { container.addOrUpdate(status: $0, save: false) }
container.backgroundContext.perform {
try! container.backgroundContext.save()
}
}
mastodonController?.persistentContainer.addAll(statuses: statuses)
}
// MARK: - Accounts
@ -104,12 +99,7 @@ class MastodonCache {
func addAll(accounts: [Account]) {
accounts.forEach(add)
if let container = mastodonController?.persistentContainer {
accounts.forEach { container.addOrUpdate(account: $0, save: false) }
container.backgroundContext.perform {
try! container.backgroundContext.save()
}
}
mastodonController?.persistentContainer.addAll(accounts: accounts)
}
// MARK: - Relationships

View File

@ -213,7 +213,8 @@ class ComposeViewController: UIViewController {
replyAvatarImageViewTopConstraint!.isActive = true
inReplyToContainer.isHidden = false
inReplyToLabel.text = "In reply to \(inReplyTo.account.displayOrUserName)"
// todo: update to use managed objects
inReplyToLabel.text = "In reply to \(inReplyTo.account.displayName)"
}
override func viewWillAppear(_ animated: Bool) {

View File

@ -128,7 +128,7 @@ class ProfileTableViewController: EnhancedTableViewController {
}
@objc func updateUIForPreferences() {
guard let accountID = accountID, let account = mastodonController.cache.account(for: accountID) else { return }
guard let accountID = accountID, let account = mastodonController.persistentContainer.account(for: accountID) else { return }
navigationItem.title = account.displayNameWithoutCustomEmoji
}

View File

@ -59,12 +59,15 @@ class TimelineTableViewController: EnhancedTableViewController {
let request = Client.getStatuses(timeline: timeline)
mastodonController.run(request) { response in
guard case let .success(statuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: statuses)
// self.mastodonController.cache.addAll(statuses: statuses)
// todo: possible race condition here? we update the underlying data before waiting to reload the table view
self.timelineSegments.insert(statuses.map { ($0.id, .unknown) }, at: 0)
self.newer = pagination?.newer
self.older = pagination?.older
DispatchQueue.main.async {
self.tableView.reloadData()
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
}
@ -101,13 +104,15 @@ class TimelineTableViewController: EnhancedTableViewController {
mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
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) })
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
}
}
}
}
@ -133,25 +138,27 @@ class TimelineTableViewController: EnhancedTableViewController {
mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.newer = pagination?.newer
self.mastodonController.cache.addAll(statuses: newStatuses)
// self.mastodonController.cache.addAll(statuses: newStatuses)
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
if let newer = pagination?.newer {
self.newer = newer
}
DispatchQueue.main.async {
let newIndexPaths = (0..<newStatuses.count).map {
IndexPath(row: $0, section: 0)
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
let newIndexPaths = (0..<newStatuses.count).map {
IndexPath(row: $0, section: 0)
}
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to the top)
self.tableView.scrollToRow(at: IndexPath(row: newStatuses.count, section: 0), at: .top, animated: false)
}
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to the top)
self.tableView.scrollToRow(at: IndexPath(row: newStatuses.count, section: 0), at: .top, animated: false)
}
}
}
@ -175,7 +182,7 @@ extension TimelineTableViewController: StatusTableViewCellDelegate {
extension TimelineTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statusID(for: indexPath)) else { continue }
guard let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
@ -185,7 +192,7 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statusID(for: indexPath)) else { continue }
guard let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments {
ImageCache.attachments.cancelWithoutCallback(attachment.url)

View File

@ -32,12 +32,13 @@ class UserActivityManager {
// MARK: - New Post
static func newPostActivity(mentioning: Account? = nil) -> NSUserActivity {
// todo: update to use managed objects
let activity = NSUserActivity(type: .newPost)
activity.isEligibleForPrediction = true
if let mentioning = mentioning {
activity.userInfo = ["mentioning": mentioning.acct]
activity.title = "Send a message to \(mentioning.displayOrUserName)"
activity.suggestedInvocationPhrase = "Send a message to \(mentioning.displayOrUserName)"
activity.title = "Send a message to \(mentioning.displayName)"
activity.suggestedInvocationPhrase = "Send a message to \(mentioning.displayName)"
} else {
activity.userInfo = [:]
activity.title = "New Post"

View File

@ -48,7 +48,7 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface
func updateUI(status: Status) {
func updateUI(status: StatusMO) {
self.statusID = status.id
attachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) }

View File

@ -83,6 +83,15 @@ class EmojiLabel: UILabel {
extension EmojiLabel {
func updateForAccountDisplayName(account: Account) {
if Preferences.shared.hideCustomEmojiInUsernames {
self.text = account.displayName
self.removeEmojis()
} else {
self.text = account.displayName
self.setEmojis(account.emojis, identifier: account.id)
}
}
func updateForAccountDisplayName(account: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
self.text = account.displayNameWithoutCustomEmoji
self.removeEmojis()

View File

@ -138,13 +138,14 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
let peopleStr: String
// todo: figure out how to localize this
// todo: update to use managed objects
switch people.count {
case 1:
peopleStr = people.first!.displayOrUserName
peopleStr = people.first!.displayName
case 2:
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName
peopleStr = people.first!.displayName + " and " + people.last!.displayName
default:
peopleStr = people.dropLast().map { $0.displayOrUserName }.joined(separator: ", ") + ", and " + people.last!.displayOrUserName
peopleStr = people.dropLast().map { $0.displayName }.joined(separator: ", ") + ", and " + people.last!.displayName
}
actionLabel.text = "\(verb) by \(peopleStr)"
}

View File

@ -72,15 +72,16 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
}
func updateActionLabel(people: [Account]) {
// todo: update to use managed objects
// todo: figure out how to localize this
let peopleStr: String
switch people.count {
case 1:
peopleStr = people.first!.displayOrUserName
peopleStr = people.first!.displayName
case 2:
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName
peopleStr = people.first!.displayName + " and " + people.last!.displayName
default:
peopleStr = people.dropLast().map { $0.displayOrUserName }.joined(separator: ", ") + ", and " + people.last!.displayOrUserName
peopleStr = people.dropLast().map { $0.displayName }.joined(separator: ", ") + ", and " + people.last!.displayName
}
actionLabel.text = "Followed by \(peopleStr)"

View File

@ -52,12 +52,13 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
}
func updateUI(account: Account) {
// todo: update to use managed objects
self.account = account
if Preferences.shared.hideCustomEmojiInUsernames {
actionLabel.text = "Request to follow from \(account.displayNameWithoutCustomEmoji)"
actionLabel.text = "Request to follow from \(account.displayName)"
actionLabel.removeEmojis()
} else {
actionLabel.text = "Request to follow from \(account.displayOrUserName)"
actionLabel.text = "Request to follow from \(account.displayName)"
actionLabel.setEmojis(account.emojis, identifier: account.id)
}
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in

View File

@ -95,25 +95,26 @@ class BaseStatusTableViewCell: UITableViewCell {
}
open func createObserversIfNecessary() {
if statusUpdater == nil {
statusUpdater = mastodonController.cache.statusSubject
.filter { [unowned self] in $0.id == self.statusID }
.receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateStatusState(status: $0) }
}
if accountUpdater == nil {
accountUpdater = mastodonController.cache.accountSubject
.filter { [unowned self] in $0.id == self.accountID }
.receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateUI(account: $0) }
}
// todo: KVO on StatusMO for this?
// if statusUpdater == nil {
// statusUpdater = mastodonController.cache.statusSubject
// .filter { [unowned self] in $0.id == self.statusID }
// .receive(on: DispatchQueue.main)
// .sink { [unowned self] in self.updateStatusState(status: $0) }
// }
//
// if accountUpdater == nil {
// accountUpdater = mastodonController.cache.accountSubject
// .filter { [unowned self] in $0.id == self.accountID }
// .receive(on: DispatchQueue.main)
// .sink { [unowned self] in self.updateUI(account: $0) }
// }
}
func updateUI(statusID: String, state: StatusState) {
createObserversIfNecessary()
guard let status = mastodonController.cache.status(for: statusID) else {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Missing cached status")
}
self.statusID = statusID
@ -161,9 +162,9 @@ class BaseStatusTableViewCell: UITableViewCell {
}
}
func updateStatusState(status: Status) {
favorited = status.favourited ?? false
reblogged = status.reblogged ?? false
func updateStatusState(status: StatusMO) {
favorited = status.favourited
reblogged = status.reblogged
if favorited {
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
@ -177,22 +178,22 @@ class BaseStatusTableViewCell: UITableViewCell {
}
}
func updateUI(account: Account) {
func updateUI(account: AccountMO) {
usernameLabel.text = "@\(account.acct)"
avatarImageView.image = nil
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == account.id else { return }
DispatchQueue.main.async {
guard let self = self, let data = data, self.accountID == account.id else { return }
self.avatarImageView.image = UIImage(data: data)
}
}
}
@objc func updateUIForPreferences() {
guard let mastodonController = mastodonController, let account = mastodonController.cache.account(for: accountID) else { return }
guard let mastodonController = mastodonController, let account = mastodonController.persistentContainer.account(for: accountID) else { return }
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account)
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.cache.status(for: statusID)?.sensitive ?? false)
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false)
}
override func prepareForReuse() {
@ -311,7 +312,7 @@ class BaseStatusTableViewCell: UITableViewCell {
extension BaseStatusTableViewCell: AttachmentViewDelegate {
func attachmentViewGallery(startingAt index: Int) -> UIViewController {
guard let status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
return delegate!.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
}

View File

@ -49,7 +49,7 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
timestampAndClientLabel.text = timestampAndClientText
}
override func updateStatusState(status: Status) {
override func updateStatusState(status: StatusMO) {
super.updateStatusState(status: status)
// todo: localize me
@ -57,7 +57,7 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
totalReblogsButton.setTitle("\(status.reblogsCount) Reblog\(status.reblogsCount == 1 ? "" : "s")", for: .normal)
}
override func updateUI(account: Account) {
override func updateUI(account: AccountMO) {
super.updateUI(account: account)
profileAccessibilityElement.accessibilityLabel = account.displayNameWithoutCustomEmoji

View File

@ -47,20 +47,20 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
override func createObserversIfNecessary() {
super.createObserversIfNecessary()
if rebloggerAccountUpdater == nil {
rebloggerAccountUpdater = mastodonController.cache.accountSubject
.filter { [unowned self] in $0.id == self.rebloggerID }
.receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateRebloggerLabel(reblogger: $0) }
}
// todo: use KVO on reblogger account?
// if rebloggerAccountUpdater == nil {
// rebloggerAccountUpdater = mastodonController.cache.accountSubject
// .filter { [unowned self] in $0.id == self.rebloggerID }
// .receive(on: DispatchQueue.main)
// .sink { [unowned self] in self.updateRebloggerLabel(reblogger: $0) }
// }
}
override func updateUI(statusID: String, state: StatusState) {
guard var status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
guard var status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
let realStatusID: String
if let rebloggedStatusID = status.reblog?.id,
let rebloggedStatus = mastodonController.cache.status(for: rebloggedStatusID) {
if let rebloggedStatus = status.reblog {
reblogStatusID = statusID
rebloggerID = status.account.id
status = rebloggedStatus
@ -77,7 +77,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
updateTimestamp()
let pinned = status.pinned ?? false
let pinned = status.pinned
pinImageView.isHidden = !(pinned && showPinned)
timestampLabel.isHidden = !pinImageView.isHidden
}
@ -85,12 +85,12 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
@objc override func updateUIForPreferences() {
super.updateUIForPreferences()
if let rebloggerID = rebloggerID,
let reblogger = mastodonController.cache.account(for: rebloggerID) {
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger)
}
}
private func updateRebloggerLabel(reblogger: Account) {
private func updateRebloggerLabel(reblogger: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)"
reblogLabel.removeEmojis()
@ -104,7 +104,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
// if the mastodonController is nil (i.e. the delegate is nil), then the screen this cell was a part of has been deallocated
// so we bail out immediately, since there's nothing to update
guard let mastodonController = mastodonController else { return }
guard let status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
timestampLabel.text = status.createdAt.timeAgoString()
timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date())

View File

@ -15,7 +15,7 @@ class StatusContentTextView: ContentTextView {
didSet {
guard let statusID = statusID else { return }
guard let mastodonController = mastodonController,
let status = mastodonController.cache.status(for: statusID) else {
let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Can't set StatusContentTextView text without cached status for \(statusID)")
}
setTextFromHtml(status.content)

View File

@ -314,7 +314,8 @@ struct XCBActions {
DispatchQueue.main.async {
show(vc)
}
let alertController = UIAlertController(title: "Follow \(account.displayNameWithoutCustomEmoji)?", message: nil, preferredStyle: .alert)
// todo: update to use managed objects
let alertController = UIAlertController(title: "Follow \(account.displayName)?", message: nil, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
performAction(account)
}))