Compare commits

..

23 Commits

Author SHA1 Message Date
Shadowfacts 4fdafa893e
Add drawing attachments using PencilKit 2020-05-09 22:14:48 -04:00
Shadowfacts 9f75106706
Fix crash when opening statuses in Safari 2020-05-09 13:31:07 -04:00
Shadowfacts bbd7d82620
Fix test in ContentTextView not being de-selectable 2020-05-07 21:46:59 -04:00
Shadowfacts 02088b1f55
Remove MastodonCache 🎉 2020-05-06 23:29:57 -04:00
Shadowfacts 1e41c8fa17
Remove MastodonCache usgae from XCBActions 2020-05-06 23:05:15 -04:00
Shadowfacts ebbfc7a132
Fix race condition on loading notifications 2020-05-06 19:32:32 -04:00
Shadowfacts aa625a41f5
Merge branch 'develop' into coredata 2020-05-06 19:18:58 -04:00
Shadowfacts 90bc9b91de
Add AccountProtocol and StatusProtocol
Provides a single interfaces for API and CoreData statuses and accounts
2020-05-06 18:40:12 -04:00
Shadowfacts d6c506488b
Replace a bunch of MastodonCache uses with CoreData 2020-05-02 19:52:35 -04:00
Shadowfacts 5786c24846
Fix statuses/accounts updating 2020-05-02 12:45:28 -04:00
Shadowfacts 2cba168804
Fix account cells using old cache 2020-04-27 19:33:36 -04:00
Shadowfacts 49d00bb1b0
Fix swipe actions not showing up 2020-04-27 19:32:16 -04:00
Shadowfacts ee5e049355
Use CoreData for bookmarks and search results 2020-04-27 19:25:41 -04:00
Shadowfacts f53474ac90
Use CoreData for notifications screen 2020-04-27 19:20:09 -04:00
Shadowfacts fa1daa682f
Convert profile VC to use CoreData objects
Does not yet remove old statuses when scrolling up, like timeline VC
2020-04-13 22:51:21 -04:00
Shadowfacts 030bee1948
Convert conversation VC to use CoreData models 2020-04-13 22:51:15 -04:00
Shadowfacts ed37b16463
Start adding CoreData-based "reference" counting for statuses
Prune old statuses that aren't likely to be shown again when scrolling
in timeline table view
2020-04-12 23:08:33 -04:00
Shadowfacts 2c8ba878b7
Start converting UI to use CoreData backed objects instead of API
objects directly
2020-04-12 12:54:27 -04:00
Shadowfacts a0e95d4577
Remove unnecessary attachment decoding code
For some reason, creating a URL from a string decoded from the container
was producing URL objects that could not be round-tripped through
PropertyListEncoder/Decoder. Decoding a URL directly from the container
works correctly.
2020-04-12 12:52:51 -04:00
Shadowfacts 465aedd43f
Make account info username optional
Onboarding view controller needs to set the account info object on the
mastodon controller before calling getOwnAccount since getOwnAccount
will upsert the user's account into the persistent container, which
requires the account info to exist to create a unique-per-account
identifier.
2020-04-12 11:14:10 -04:00
Shadowfacts 102fe6ed91
Convert API objects to CoreData models and save them 2020-04-11 22:23:31 -04:00
Shadowfacts 7deb4fc5b4
Add LazilyDecoding for CoreData embedded objects 2020-04-11 15:35:00 -04:00
Shadowfacts 2a419eb87c
Add basic Status/Account CoreData model 2020-04-11 15:32:25 -04:00
61 changed files with 1522 additions and 595 deletions

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class Account: Decodable { public final class Account: AccountProtocol, Decodable {
public let id: String public let id: String
public let username: String public let username: String
public let acct: String public let acct: String
@ -27,7 +27,7 @@ public class Account: Decodable {
public private(set) var emojis: [Emoji] public private(set) var emojis: [Emoji]
public let moved: Bool? public let moved: Bool?
public let movedTo: Account? public let movedTo: Account?
public let fields: [Field]? public let fields: [Field]
public let bot: Bool? public let bot: Bool?
public required init(from decoder: Decoder) throws { public required init(from decoder: Decoder) throws {
@ -49,7 +49,7 @@ public class Account: Decodable {
self.header = try container.decode(URL.self, forKey: .header) self.header = try container.decode(URL.self, forKey: .header)
self.headerStatic = try container.decode(URL.self, forKey: .url) self.headerStatic = try container.decode(URL.self, forKey: .url)
self.emojis = try container.decode([Emoji].self, forKey: .emojis) self.emojis = try container.decode([Emoji].self, forKey: .emojis)
self.fields = try? container.decode([Field].self, forKey: .fields) self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? []
self.bot = try? container.decode(Bool.self, forKey: .bot) self.bot = try? container.decode(Bool.self, forKey: .bot)
if let moved = try? container.decode(Bool.self, forKey: .moved) { if let moved = try? container.decode(Bool.self, forKey: .moved) {

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class Attachment: Decodable { public class Attachment: Codable {
public let id: String public let id: String
public let kind: Kind public let kind: Kind
public let url: URL public let url: URL
@ -29,18 +29,10 @@ public class Attachment: Decodable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)
self.kind = try container.decode(Kind.self, forKey: .kind) self.kind = try container.decode(Kind.self, forKey: .kind)
self.url = URL(string: try container.decode(String.self, forKey: .url))! self.url = try container.decode(URL.self, forKey: .url)
self.previewURL = URL(string: try container.decode(String.self, forKey: .previewURL))! self.previewURL = try container.decode(URL.self, forKey: .previewURL)
if let remote = try? container.decode(String.self, forKey: .remoteURL) { self.remoteURL = try? container.decode(URL.self, forKey: .remoteURL)
self.remoteURL = URL(string: remote)! self.textURL = try? container.decode(URL.self, forKey: .textURL)
} else {
self.remoteURL = nil
}
if let text = try? container.decode(String.self, forKey: .textURL) {
self.textURL = URL(string: text)!
} else {
self.textURL = nil
}
self.meta = try? container.decode(Metadata.self, forKey: .meta) self.meta = try? container.decode(Metadata.self, forKey: .meta)
self.description = try? container.decode(String.self, forKey: .description) self.description = try? container.decode(String.self, forKey: .description)
} }
@ -58,7 +50,7 @@ public class Attachment: Decodable {
} }
extension Attachment { extension Attachment {
public enum Kind: String, Decodable { public enum Kind: String, Codable {
case image case image
case video case video
case gifv case gifv
@ -68,7 +60,7 @@ extension Attachment {
} }
extension Attachment { extension Attachment {
public class Metadata: Decodable { public class Metadata: Codable {
public let length: String? public let length: String?
public let duration: Float? public let duration: Float?
public let audioEncoding: String? public let audioEncoding: String?
@ -99,7 +91,7 @@ extension Attachment {
} }
} }
public class ImageMetadata: Decodable { public class ImageMetadata: Codable {
public let width: Int? public let width: Int?
public let height: Int? public let height: Int?
public let size: String? public let size: String?

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class Emoji: Decodable { public class Emoji: Codable {
public let shortcode: String public let shortcode: String
public let url: URL public let url: URL
public let staticURL: URL public let staticURL: URL

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class Mention: Decodable { 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

View File

@ -0,0 +1,33 @@
//
// AccountProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public protocol AccountProtocol {
associatedtype Account: AccountProtocol
var id: String { get }
var username: String { get }
var acct: String { get }
var displayName: String { get }
var locked: Bool { get }
var createdAt: Date { get }
var followersCount: Int { get }
var followingCount: Int { get }
var statusesCount: Int { get }
var note: String { get }
var url: URL { get }
var avatar: URL { get }
var header: URL { get }
var moved: Bool? { get }
var bot: Bool? { get }
var movedTo: Account? { get }
var emojis: [Emoji] { get }
var fields: [Pachyderm.Account.Field] { get }
}

View File

@ -0,0 +1,38 @@
//
// StatusProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public protocol StatusProtocol {
associatedtype Status: StatusProtocol
associatedtype Account: AccountProtocol
var id: String { get }
var uri: String { get }
var inReplyToID: String? { get }
var inReplyToAccountID: String? { get }
var content: String { get }
var createdAt: Date { get }
var reblogsCount: Int { get }
var favouritesCount: Int { get }
var reblogged: Bool { get }
var favourited: Bool { get }
var sensitive: Bool { get }
var spoilerText: String { get }
var visibility: Pachyderm.Status.Visibility { get }
var applicationName: String? { get }
var pinned: Bool? { get }
var bookmarked: Bool? { get }
var account: Account { get }
var reblog: Status? { get }
var attachments: [Attachment] { get }
var emojis: [Emoji] { get }
var hashtags: [Hashtag] { get }
var mentions: [Mention] { get }
}

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class Status: Decodable { public final class Status: StatusProtocol, Decodable {
public let id: String public let id: String
public let uri: String public let uri: String
public let url: URL? public let url: URL?
@ -23,8 +23,8 @@ public class Status: Decodable {
// public let repliesCount: Int // public let repliesCount: Int
public let reblogsCount: Int public let reblogsCount: Int
public let favouritesCount: Int public let favouritesCount: Int
public let reblogged: Bool? public let reblogged: Bool
public let favourited: Bool? public let favourited: Bool
public let muted: Bool? public let muted: Bool?
public let sensitive: Bool public let sensitive: Bool
public let spoilerText: String public let spoilerText: String
@ -38,22 +38,24 @@ public class Status: Decodable {
public let bookmarked: Bool? public let bookmarked: Bool?
public let card: Card? public let card: Card?
public static func getContext(_ status: Status) -> Request<ConversationContext> { public var applicationName: String? { application?.name }
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
} }
public static func getCard(_ status: Status) -> Request<Card> { public static func getCard(_ status: Status) -> Request<Card> {
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card") return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
} }
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> { public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by") var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
request.range = range request.range = range
return request return request
} }
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> { public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by") var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
request.range = range request.range = range
return request return request
} }
@ -62,20 +64,20 @@ public class Status: Decodable {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)") return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
} }
public static func reblog(_ status: Status) -> Request<Status> { public static func reblog(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog") return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
} }
public static func unreblog(_ status: Status) -> Request<Status> { public static func unreblog(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog") return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
} }
public static func favourite(_ status: Status) -> Request<Status> { public static func favourite(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite") return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
} }
public static func unfavourite(_ status: Status) -> Request<Status> { public static func unfavourite(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite") return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
} }
public static func pin(_ status: Status) -> Request<Status> { public static func pin(_ status: Status) -> Request<Status> {
@ -90,8 +92,8 @@ public class Status: Decodable {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/bookmark") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/bookmark")
} }
public static func unbookmark(_ status: Status) -> Request<Status> { public static func unbookmark(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unbookmark") return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
} }
public static func muteConversation(_ status: Status) -> Request<Status> { public static func muteConversation(_ status: Status) -> Request<Status> {

View File

@ -9,14 +9,14 @@
import Foundation import Foundation
public class NotificationGroup { public class NotificationGroup {
public let notificationIDs: [String] public let notifications: [Notification]
public let id: String public let id: String
public let kind: Notification.Kind public let kind: Notification.Kind
public let statusState: StatusState? public let statusState: StatusState?
init?(notifications: [Notification]) { init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil } guard !notifications.isEmpty else { return nil }
self.notificationIDs = notifications.map { $0.id } self.notifications = notifications
self.id = notifications.first!.id self.id = notifications.first!.id
self.kind = notifications.first!.kind self.kind = notifications.first!.kind
if kind == .mention { if kind == .mention {

View File

@ -20,9 +20,14 @@
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; };
D6028B9B2150811100F223B9 /* MastodonCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6028B9A2150811100F223B9 /* MastodonCache.swift */; };
D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */; }; D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */; };
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */; }; D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */; };
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; };
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; };
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */; };
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */; };
D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F3024424F1A005F8713 /* StatusProtocol.swift */; };
D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F3224425374005F8713 /* AccountProtocol.swift */; };
D61099B42144B0CC00432DC2 /* Pachyderm.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61099AB2144B0CC00432DC2 /* Pachyderm.framework */; }; D61099B42144B0CC00432DC2 /* Pachyderm.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61099AB2144B0CC00432DC2 /* Pachyderm.framework */; };
D61099BB2144B0CC00432DC2 /* PachydermTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099BA2144B0CC00432DC2 /* PachydermTests.swift */; }; D61099BB2144B0CC00432DC2 /* PachydermTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099BA2144B0CC00432DC2 /* PachydermTests.swift */; };
D61099BD2144B0CC00432DC2 /* Pachyderm.h in Headers */ = {isa = PBXBuildFile; fileRef = D61099AD2144B0CC00432DC2 /* Pachyderm.h */; settings = {ATTRIBUTES = (Public, ); }; }; D61099BD2144B0CC00432DC2 /* Pachyderm.h in Headers */ = {isa = PBXBuildFile; fileRef = D61099AD2144B0CC00432DC2 /* Pachyderm.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -105,6 +110,7 @@
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
D63569E023908A8D003DD353 /* StatusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60A4FFB238B726A008AC647 /* StatusState.swift */; }; D63569E023908A8D003DD353 /* StatusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60A4FFB238B726A008AC647 /* StatusState.swift */; };
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; }; D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
D63F9C66241C4CC3004C03CF /* AddAttachmentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */; }; D63F9C66241C4CC3004C03CF /* AddAttachmentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */; };
D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */; }; D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */; };
D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */; }; D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */; };
@ -126,6 +132,7 @@
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC19123C271D9000D0238 /* MastodonActivity.swift */; }; D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC19123C271D9000D0238 /* MastodonActivity.swift */; };
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; }; D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* CachedDictionary.swift */; };
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; }; D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; }; D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; }; D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; };
@ -150,6 +157,7 @@
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; }; D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; }; D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; };
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; }; D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; };
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; };
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; }; D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; };
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; }; D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; };
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; }; D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; };
@ -157,6 +165,7 @@
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */; }; D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */; };
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */; }; D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */; };
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68015412401A74600D6103B /* MediaPrefsView.swift */; }; D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68015412401A74600D6103B /* MediaPrefsView.swift */; };
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; }; D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; }; D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; }; D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
@ -303,10 +312,15 @@
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; }; 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; }; 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
D6028B9A2150811100F223B9 /* MastodonCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCache.swift; sourceTree = "<group>"; };
D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsViewController.swift; sourceTree = "<group>"; }; D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsViewController.swift; sourceTree = "<group>"; };
D60A4FFB238B726A008AC647 /* StatusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusState.swift; sourceTree = "<group>"; }; D60A4FFB238B726A008AC647 /* StatusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusState.swift; sourceTree = "<group>"; };
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; }; D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = "<group>"; };
D60E2F252442372B005F8713 /* AccountMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMO.swift; sourceTree = "<group>"; };
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazilyDecoding.swift; sourceTree = "<group>"; };
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCachePersistentStore.swift; sourceTree = "<group>"; };
D60E2F3024424F1A005F8713 /* StatusProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProtocol.swift; sourceTree = "<group>"; };
D60E2F3224425374005F8713 /* AccountProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountProtocol.swift; sourceTree = "<group>"; };
D61099AB2144B0CC00432DC2 /* Pachyderm.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pachyderm.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D61099AB2144B0CC00432DC2 /* Pachyderm.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pachyderm.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D61099AD2144B0CC00432DC2 /* Pachyderm.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Pachyderm.h; sourceTree = "<group>"; }; D61099AD2144B0CC00432DC2 /* Pachyderm.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Pachyderm.h; sourceTree = "<group>"; };
D61099AE2144B0CC00432DC2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D61099AE2144B0CC00432DC2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -389,6 +403,7 @@
D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; }; D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; };
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; }; D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; }; D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AddAttachmentTableViewCell.xib; sourceTree = "<group>"; }; D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AddAttachmentTableViewCell.xib; sourceTree = "<group>"; };
D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAttachmentTableViewCell.swift; sourceTree = "<group>"; }; D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAttachmentTableViewCell.swift; sourceTree = "<group>"; };
D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentTableViewCell.swift; sourceTree = "<group>"; }; D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentTableViewCell.swift; sourceTree = "<group>"; };
@ -410,6 +425,7 @@
D64BC19123C271D9000D0238 /* MastodonActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonActivity.swift; sourceTree = "<group>"; }; D64BC19123C271D9000D0238 /* MastodonActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonActivity.swift; sourceTree = "<group>"; };
D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; }; D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
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 /* CachedDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedDictionary.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>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -437,6 +453,7 @@
D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; }; D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; };
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; }; D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; };
D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = "<group>"; }; D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = "<group>"; };
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = "<group>"; }; D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = "<group>"; };
D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; }; D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; };
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; }; D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; };
@ -444,6 +461,7 @@
D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusReplyView.swift; sourceTree = "<group>"; }; D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusReplyView.swift; sourceTree = "<group>"; };
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposingPrefsView.swift; sourceTree = "<group>"; }; D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposingPrefsView.swift; sourceTree = "<group>"; };
D68015412401A74600D6103B /* MediaPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPrefsView.swift; sourceTree = "<group>"; }; D68015412401A74600D6103B /* MediaPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPrefsView.swift; sourceTree = "<group>"; };
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; }; D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; };
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; }; D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
@ -581,6 +599,15 @@
path = "Attachment Gallery"; path = "Attachment Gallery";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D60E2F2F24424F0D005F8713 /* Protocols */ = {
isa = PBXGroup;
children = (
D60E2F3024424F1A005F8713 /* StatusProtocol.swift */,
D60E2F3224425374005F8713 /* AccountProtocol.swift */,
);
path = Protocols;
sourceTree = "<group>";
};
D61099AC2144B0CC00432DC2 /* Pachyderm */ = { D61099AC2144B0CC00432DC2 /* Pachyderm */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -640,6 +667,7 @@
D61099DD2144C10C00432DC2 /* Model */ = { D61099DD2144C10C00432DC2 /* Model */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D60E2F2F24424F0D005F8713 /* Protocols */,
D61099DE2144C11400432DC2 /* MastodonError.swift */, D61099DE2144C11400432DC2 /* MastodonError.swift */,
D6109A04214572BF00432DC2 /* Scope.swift */, D6109A04214572BF00432DC2 /* Scope.swift */,
D61099E02144C1DC00432DC2 /* Account.swift */, D61099E02144C1DC00432DC2 /* Account.swift */,
@ -789,6 +817,17 @@
path = Shortcuts; path = Shortcuts;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6370B9924421FE00092A7FF /* CoreData */ = {
isa = PBXGroup;
children = (
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */,
D60E2F232442372B005F8713 /* StatusMO.swift */,
D60E2F252442372B005F8713 /* AccountMO.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
);
path = CoreData;
sourceTree = "<group>";
};
D641C780213DD7C4004B4513 /* Screens */ = { D641C780213DD7C4004B4513 /* Screens */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -875,6 +914,7 @@
D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */, D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */,
D66362702136338600C9CBA2 /* ComposeViewController.swift */, D66362702136338600C9CBA2 /* ComposeViewController.swift */,
D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */, D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */,
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
); );
path = Compose; path = Compose;
sourceTree = "<group>"; sourceTree = "<group>";
@ -990,6 +1030,7 @@
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */, D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */,
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */, D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */,
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */, D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */,
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1213,9 +1254,10 @@
D64D0AAC2128D88B005A6F37 /* LocalData.swift */, D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D627FF75217E923E00CC0648 /* DraftsManager.swift */, D627FF75217E923E00CC0648 /* DraftsManager.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6028B9A2150811100F223B9 /* MastodonCache.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */, D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D6F1F84E2193B9BE00F5FE67 /* Caching */, D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */, D6757A7A2157E00100721E32 /* XCallbackURL */,
D62D241E217AA46B005076CC /* Shortcuts */, D62D241E217AA46B005076CC /* Shortcuts */,
@ -1223,6 +1265,7 @@
D6AEBB3F2321640F00E5038B /* Activities */, D6AEBB3F2321640F00E5038B /* Activities */,
D667E5F62135C2ED0057A976 /* Extensions */, D667E5F62135C2ED0057A976 /* Extensions */,
D61959D2241E846D00A37B8E /* Models */, D61959D2241E846D00A37B8E /* Models */,
D6370B9924421FE00092A7FF /* CoreData */,
D6F953F121251A2F00CF0F2B /* Controllers */, D6F953F121251A2F00CF0F2B /* Controllers */,
D641C780213DD7C4004B4513 /* Screens */, D641C780213DD7C4004B4513 /* Screens */,
D6BED1722126661300F02DA0 /* Views */, D6BED1722126661300F02DA0 /* Views */,
@ -1558,7 +1601,9 @@
D61099CB2144B20500432DC2 /* Request.swift in Sources */, D61099CB2144B20500432DC2 /* Request.swift in Sources */,
D6109A05214572BF00432DC2 /* Scope.swift in Sources */, D6109A05214572BF00432DC2 /* Scope.swift in Sources */,
D6109A11214607D500432DC2 /* Timeline.swift in Sources */, D6109A11214607D500432DC2 /* Timeline.swift in Sources */,
D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */,
D61099E7214561FF00432DC2 /* Attachment.swift in Sources */, D61099E7214561FF00432DC2 /* Attachment.swift in Sources */,
D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */,
D61099D02144B2D700432DC2 /* Method.swift in Sources */, D61099D02144B2D700432DC2 /* Method.swift in Sources */,
D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */, D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */,
D61099FB214569F600432DC2 /* Report.swift in Sources */, D61099FB214569F600432DC2 /* Report.swift in Sources */,
@ -1606,6 +1651,7 @@
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */, D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */, 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */, D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */, D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */, D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */, 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
@ -1632,14 +1678,16 @@
D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */, D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */,
D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */, D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */,
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */, D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
D620483623D38075008A63EF /* ContentTextView.swift in Sources */, D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
D6028B9B2150811100F223B9 /* MastodonCache.swift in Sources */,
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */, D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */, D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */, D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
@ -1651,6 +1699,7 @@
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */, D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */, D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */, D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
@ -1715,14 +1764,17 @@
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */, D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */, D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */,
D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */, D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */, D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */,
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */, D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */, D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */, D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */, D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */, D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */,
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */, D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */, D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */,
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */, 0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
@ -2231,6 +2283,19 @@
productName = SheetController; productName = SheetController;
}; };
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */ = {
isa = XCVersionGroup;
children = (
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */,
);
currentVersion = D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */;
path = Tusker.xcdatamodeld;
sourceTree = "<group>";
versionGroupType = wrapper.xcdatamodel;
};
/* End XCVersionGroup section */
}; };
rootObject = D6D4DDC4212518A000E1C4BB /* Project object */; rootObject = D6D4DDC4212518A000E1C4BB /* Project object */;
} }

View File

@ -92,6 +92,12 @@
ReferencedContainer = "container:Tusker.xcodeproj"> ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View File

@ -29,9 +29,7 @@ class FollowAccountActivity: AccountActivity {
let request = Account.follow(account.id) let request = Account.follow(account.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(relationship, _) = response { if case .failure(_) = response {
self.mastodonController.cache.add(relationship: relationship)
} else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError() fatalError()

View File

@ -29,9 +29,7 @@ class UnfollowAccountActivity: AccountActivity {
let request = Account.unfollow(account.id) let request = Account.unfollow(account.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(relationship, _) = response { if case .failure(_) = response {
self.mastodonController.cache.add(relationship: relationship)
} else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError() fatalError()

View File

@ -29,7 +29,7 @@ class BookmarkStatusActivity: StatusActivity {
let request = Status.bookmark(status) let request = Status.bookmark(status)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.cache.add(status: status) self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -28,7 +28,7 @@ class PinStatusActivity: StatusActivity {
let request = Status.pin(status) let request = Status.pin(status)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.cache.add(status: status) self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -26,10 +26,10 @@ class UnbookmarkStatusActivity: StatusActivity {
override func perform() { override func perform() {
guard let status = status else { return } guard let status = status else { return }
let request = Status.unbookmark(status) let request = Status.unbookmark(status.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.cache.add(status: status) self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -28,7 +28,7 @@ class UnpinStatusActivity: StatusActivity {
let request = Status.unpin(status) let request = Status.unpin(status)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.cache.add(status: status) self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -0,0 +1,35 @@
//
// CachedDictionary.swift
// Tusker
//
// Created by Shadowfacts on 5/6/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
class CachedDictionary<Value> {
private let name: String
private var dict = [String: Value]()
private let queue: DispatchQueue
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: String) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
}

View File

@ -30,10 +30,10 @@ class MastodonController {
} }
} }
private(set) lazy var cache = MastodonCache(mastodonController: self) private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: self)
let instanceURL: URL let instanceURL: URL
private(set) var accountInfo: LocalData.UserAccountInfo? var accountInfo: LocalData.UserAccountInfo?
let client: Client! let client: Client!
@ -82,7 +82,7 @@ class MastodonController {
run(request) { response in run(request) { response in
guard case let .success(account, _) = response else { fatalError() } guard case let .success(account, _) = response else { fatalError() }
self.account = account self.account = account
self.cache.add(account: account) self.persistentContainer.addOrUpdate(account: account)
completion?(account) completion?(account)
} }
} }

View File

@ -0,0 +1,86 @@
//
// AccountMO.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
//
import Foundation
import CoreData
import Pachyderm
@objc(AccountMO)
public final class AccountMO: NSManagedObject, AccountProtocol {
@nonobjc public class func fetchRequest() -> NSFetchRequest<AccountMO> {
return NSFetchRequest<AccountMO>(entityName: "Account")
}
@NSManaged public var acct: String
@NSManaged public var avatar: URL
@NSManaged public var botCD: Bool
@NSManaged public var createdAt: Date
@NSManaged public var displayName: String
@NSManaged private var emojisData: Data?
@NSManaged private var fieldsData: Data?
@NSManaged public var followersCount: Int
@NSManaged public var followingCount: Int
@NSManaged public var header: URL
@NSManaged public var id: String
@NSManaged public var locked: Bool
@NSManaged public var movedCD: Bool
@NSManaged public var note: String
@NSManaged public var statusesCount: Int
@NSManaged public var url: URL
@NSManaged public var username: String
@NSManaged public var movedTo: AccountMO?
@LazilyDecoding(arrayFrom: \AccountMO.emojisData)
public var emojis: [Emoji]
@LazilyDecoding(arrayFrom: \AccountMO.fieldsData)
public var fields: [Pachyderm.Account.Field]
public var bot: Bool? { botCD }
public var moved: Bool? { movedCD }
}
extension AccountMO {
convenience init(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiAccount: account, container: container)
}
func updateFrom(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore) {
guard let context = managedObjectContext else {
// we've been deleted, don't bother updating
return
}
self.acct = account.acct
self.avatar = account.avatarStatic // we don't animate avatars
self.botCD = account.bot ?? false
self.createdAt = account.createdAt
self.displayName = account.displayName
self.emojis = account.emojis
self.fields = account.fields
self.followersCount = account.followersCount
self.followingCount = account.followingCount
self.header = account.headerStatic // we don't animate headers
self.id = account.id
self.locked = account.locked
self.movedCD = account.moved ?? false
self.note = account.note
self.statusesCount = account.statusesCount
self.url = account.url
self.username = account.username
if let movedTo = account.movedTo {
self.movedTo = container.account(for: movedTo.id, in: context) ?? AccountMO(apiAccount: movedTo, container: container, context: context)
} else {
self.movedTo = nil
}
}
}

View File

@ -0,0 +1,142 @@
//
// MastodonCachePersistentStore.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
import Combine
class MastodonCachePersistentStore: NSPersistentContainer {
private(set) lazy var backgroundContext = newBackgroundContext()
let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>()
init(for controller: MastodonController) {
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
let model = NSManagedObjectModel(contentsOf: url)!
super.init(name: "\(controller.accountInfo!.id)_cache", managedObjectModel: model)
loadPersistentStores { (description, error) in
if let error = error {
fatalError("Unable to load persistent store: \(error)")
}
}
}
func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? {
let context = context ?? viewContext
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let status = result.first {
return status
} else {
return nil
}
}
@discardableResult
private func upsert(status: Status, incrementReferenceCount: Bool) -> StatusMO {
if let statusMO = self.status(for: status.id, in: self.backgroundContext) {
statusMO.updateFrom(apiStatus: status, container: self)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO
} else {
let statusMO = StatusMO(apiStatus: status, container: self, context: self.backgroundContext)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO
}
}
func addOrUpdate(status: Status, incrementReferenceCount: Bool, completion: ((StatusMO) -> Void)? = nil) {
backgroundContext.perform {
let statusMO = self.upsert(status: status, incrementReferenceCount: incrementReferenceCount)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?(statusMO)
self.statusSubject.send(status.id)
}
}
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform {
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
}
}
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
let context = context ?? viewContext
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let account = result.first {
return account
} else {
return nil
}
}
@discardableResult
private func upsert(account: Account) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self)
return accountMO
} else {
return AccountMO(apiAccount: account, container: self, context: self.backgroundContext)
}
}
func addOrUpdate(account: Account, completion: ((AccountMO) -> Void)? = nil) {
backgroundContext.perform {
let accountMO = self.upsert(account: account)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?(accountMO)
self.accountSubject.send(account.id)
}
}
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform {
accounts.forEach { self.upsert(account: $0) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
accounts.forEach { self.accountSubject.send($0.id) }
}
}
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
backgroundContext.perform {
let statuses = notifications.compactMap { $0.status }
let accounts = notifications.map { $0.account }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
accounts.forEach { self.upsert(account: $0) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
accounts.forEach { self.accountSubject.send($0.id) }
}
}
}

View File

@ -0,0 +1,132 @@
//
// StatusMO.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
//
import Foundation
import CoreData
import Pachyderm
@objc(StatusMO)
public final class StatusMO: NSManagedObject, StatusProtocol {
@nonobjc public class func fetchRequest() -> NSFetchRequest<StatusMO> {
return NSFetchRequest<StatusMO>(entityName: "Status")
}
@NSManaged public var applicationName: String?
@NSManaged private var attachmentsData: Data?
@NSManaged private var bookmarkedInternal: Bool
@NSManaged public var content: String
@NSManaged public var createdAt: Date
@NSManaged private var emojisData: Data?
@NSManaged public var favourited: Bool
@NSManaged public var favouritesCount: Int
@NSManaged private var hashtagsData: Data?
@NSManaged public var id: String
@NSManaged public var inReplyToAccountID: String?
@NSManaged public var inReplyToID: String?
@NSManaged private var mentionsData: Data?
@NSManaged public var muted: Bool
@NSManaged private var pinnedInternal: Bool
@NSManaged public var reblogged: Bool
@NSManaged public var reblogsCount: Int
@NSManaged public var referenceCount: 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
@NSManaged public var account: AccountMO
@NSManaged public var reblog: StatusMO?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment]
@LazilyDecoding(arrayFrom: \StatusMO.emojisData)
public var emojis: [Emoji]
@LazilyDecoding(arrayFrom: \StatusMO.hashtagsData)
public var hashtags: [Hashtag]
@LazilyDecoding(arrayFrom: \StatusMO.mentionsData)
public var mentions: [Mention]
public var pinned: Bool? { pinnedInternal }
public var bookmarked: Bool? { bookmarkedInternal }
public var visibility: Pachyderm.Status.Visibility {
get {
Pachyderm.Status.Visibility(rawValue: visibilityString) ?? .public
}
set {
visibilityString = newValue.rawValue
}
}
func incrementReferenceCount() {
referenceCount += 1
}
func decrementReferenceCount() {
referenceCount -= 1
if referenceCount <= 0 {
managedObjectContext!.delete(self)
}
}
public override func prepareForDeletion() {
super.prepareForDeletion()
reblog?.decrementReferenceCount()
}
}
extension StatusMO {
convenience init(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiStatus: status, container: container)
reblog?.incrementReferenceCount()
}
func updateFrom(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore) {
guard let context = managedObjectContext else {
// we have been deleted, don't bother updating
return
}
self.applicationName = status.application?.name
self.attachments = status.attachments
self.bookmarkedInternal = status.bookmarked ?? false
self.content = status.content
self.createdAt = status.createdAt
self.emojis = status.emojis
self.favourited = status.favourited
self.favouritesCount = status.favouritesCount
self.hashtags = status.hashtags
self.inReplyToAccountID = status.inReplyToAccountID
self.inReplyToID = status.inReplyToID
self.id = status.id
self.mentions = status.mentions
self.muted = status.muted ?? false
self.pinnedInternal = status.pinned ?? false
self.reblogged = status.reblogged
self.reblogsCount = status.reblogsCount
self.sensitive = status.sensitive
self.spoilerText = status.spoilerText
self.uri = status.uri
self.url = status.url
self.visibility = status.visibility
self.account = container.account(for: status.account.id, in: context) ?? AccountMO(apiAccount: status.account, container: container, context: context)
if let reblog = status.reblog {
self.reblog = container.status(for: reblog.id, in: context) ?? StatusMO(apiStatus: reblog, container: container, context: context)
} else {
self.reblog = nil
}
}
}

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19D76" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/>
<attribute name="botCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="emojisData" attributeType="Binary"/>
<attribute name="fieldsData" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="URI"/>
<attribute name="id" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="movedCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="note" attributeType="String"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="url" attributeType="URI"/>
<attribute name="username" attributeType="String"/>
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="attachmentsData" attributeType="Binary"/>
<attribute name="bookmarkedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="emojisData" attributeType="Binary" customClassName="[Data]"/>
<attribute name="favourited" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hashtagsData" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="mentionsData" attributeType="Binary"/>
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogged" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="referenceCount" 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"/>
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</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="418"/>
</elements>
</model>

View File

@ -9,7 +9,7 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
extension Account { extension AccountMO {
var displayOrUserName: String { var displayOrUserName: String {
if displayName.isEmpty { if displayName.isEmpty {
@ -31,7 +31,7 @@ extension Account {
private func stripCustomEmoji(from string: String) -> String { private func stripCustomEmoji(from string: String) -> String {
let range = NSRange(location: 0, length: string.utf16.count) 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

@ -0,0 +1,33 @@
//
// PKDrawing+Render.swift
// Tusker
//
// Created by Shadowfacts on 5/9/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import PencilKit
extension PKDrawing {
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
var drawingImage: UIImage!
lightTraitCollection.performAsCurrent {
drawingImage = self.image(from: rect, scale: scale)
}
let imageRect = CGRect(origin: .zero, size: rect.size)
let format = UIGraphicsImageRendererFormat()
format.opaque = false
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
return renderer.image { (context) in
UIColor.white.setFill()
context.fill(imageRect)
drawingImage.draw(in: imageRect)
}
}
}

View File

@ -0,0 +1,64 @@
//
// LazyDecoding.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
// todo: invalidate cache on underlying data change using KVO?
@propertyWrapper
public struct LazilyDecoding<Enclosing, Value: Codable> {
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
private let fallback: Value
private var value: Value?
init(from keyPath: ReferenceWritableKeyPath<Enclosing, Data?>, fallback: Value) {
self.keyPath = keyPath
self.fallback = fallback
}
public var wrappedValue: Value {
get { fatalError("called LazilyDecoding wrappedValue getter") }
set { fatalError("called LazilyDecoding wrappedValue setter") }
}
public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value {
get {
var wrapper = instance[keyPath: storageKeyPath]
if let value = wrapper.value {
return value
} else {
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
do {
let value = try decoder.decode(Value.self, from: data)
wrapper.value = value
instance[keyPath: storageKeyPath] = wrapper
return value
} catch {
return wrapper.fallback
}
}
}
set {
var wrapper = instance[keyPath: storageKeyPath]
wrapper.value = newValue
instance[keyPath: storageKeyPath] = wrapper
let newData = try? encoder.encode(newValue)
instance[keyPath: wrapper.keyPath] = newData
}
}
}
extension LazilyDecoding {
init(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) {
self.init(from: keyPath, fallback: [] as! Value)
}
}

View File

@ -44,11 +44,10 @@ class LocalData: ObservableObject {
let url = URL(string: instanceURL), let url = URL(string: instanceURL),
let clientId = info["clientID"], let clientId = info["clientID"],
let secret = info["clientSecret"], let secret = info["clientSecret"],
let username = info["username"],
let accessToken = info["accessToken"] else { let accessToken = info["accessToken"] else {
return nil return nil
} }
return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: username, accessToken: accessToken) return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: info["username"], accessToken: accessToken)
} }
} else { } else {
return [] return []
@ -56,15 +55,18 @@ class LocalData: ObservableObject {
} }
set { set {
objectWillChange.send() objectWillChange.send()
let array = newValue.map { (info) in let array = newValue.map { (info) -> [String: String] in
return [ var res = [
"id": info.id, "id": info.id,
"instanceURL": info.instanceURL.absoluteString, "instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID, "clientID": info.clientID,
"clientSecret": info.clientSecret, "clientSecret": info.clientSecret,
"username": info.username,
"accessToken": info.accessToken "accessToken": info.accessToken
] ]
if let username = info.username {
res["username"] = username
}
return res
} }
defaults.set(array, forKey: accountsKey) defaults.set(array, forKey: accountsKey)
} }
@ -85,7 +87,7 @@ class LocalData: ObservableObject {
return !accounts.isEmpty return !accounts.isEmpty
} }
func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String, accessToken: String) -> UserAccountInfo { func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo {
var accounts = self.accounts var accounts = self.accounts
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) { if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index) accounts.remove(at: index)
@ -97,6 +99,13 @@ class LocalData: ObservableObject {
return info return info
} }
func setUsername(for info: UserAccountInfo, username: String) {
var info = info
info.username = username
removeAccount(info)
accounts.append(info)
}
func removeAccount(_ info: UserAccountInfo) { func removeAccount(_ info: UserAccountInfo) {
accounts.removeAll(where: { $0.id == info.id }) accounts.removeAll(where: { $0.id == info.id })
} }
@ -128,7 +137,7 @@ extension LocalData {
let instanceURL: URL let instanceURL: URL
let clientID: String let clientID: String
let clientSecret: String let clientSecret: String
let username: String fileprivate(set) var username: String!
let accessToken: String let accessToken: String
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {

View File

@ -1,177 +0,0 @@
//
// StatusCache.swift
// Tusker
//
// Created by Shadowfacts on 9/17/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
import Combine
import Pachyderm
class MastodonCache {
private var statuses = CachedDictionary<Status>(name: "Statuses")
private var accounts = CachedDictionary<Account>(name: "Accounts")
private var relationships = CachedDictionary<Relationship>(name: "Relationships")
private var notifications = CachedDictionary<Pachyderm.Notification>(name: "Notifications")
let statusSubject = PassthroughSubject<Status, Never>()
let accountSubject = PassthroughSubject<Account, Never>()
weak var mastodonController: MastodonController?
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
}
// MARK: - Statuses
func status(for id: String) -> Status? {
return statuses[id]
}
func set(status: Status, for id: String) {
statuses[id] = status
add(account: status.account)
if let reblog = status.reblog {
add(status: reblog)
add(account: reblog.account)
}
statusSubject.send(status)
}
func status(for id: String, completion: @escaping (Status?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getStatus(id: id)
mastodonController.run(request) { response in
guard case let .success(status, _) = response else {
completion(nil)
return
}
self.set(status: status, for: id)
completion(status)
}
}
func add(status: Status) {
set(status: status, for: status.id)
}
func addAll(statuses: [Status]) {
statuses.forEach(add)
}
// MARK: - Accounts
func account(for id: String) -> Account? {
return accounts[id]
}
func set(account: Account, for id: String) {
accounts[id] = account
accountSubject.send(account)
}
func account(for id: String, completion: @escaping (Account?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getAccount(id: id)
mastodonController.run(request) { response in
guard case let .success(account, _) = response else {
completion(nil)
return
}
self.set(account: account, for: account.id)
completion(account)
}
}
func add(account: Account) {
set(account: account, for: account.id)
}
func addAll(accounts: [Account]) {
accounts.forEach(add)
}
// MARK: - Relationships
func relationship(for id: String) -> Relationship? {
return relationships[id]
}
func set(relationship: Relationship, id: String) {
relationships[id] = relationship
}
func relationship(for id: String, completion: @escaping (Relationship?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getRelationships(accounts: [id])
mastodonController.run(request) { response in
guard case let .success(relationships, _) = response,
let relationship = relationships.first else {
completion(nil)
return
}
self.set(relationship: relationship, id: relationship.id)
completion(relationship)
}
}
func add(relationship: Relationship) {
set(relationship: relationship, id: relationship.id)
}
func addAll(relationships: [Relationship]) {
relationships.forEach(add)
}
// MARK: - Notifications
func notification(for id: String) -> Pachyderm.Notification? {
return notifications[id]
}
func set(notification: Pachyderm.Notification, id: String) {
notifications[id] = notification
}
func add(notification: Pachyderm.Notification) {
set(notification: notification, id: notification.id)
}
func addAll(notifications: [Pachyderm.Notification]) {
notifications.forEach(add)
}
}
class CachedDictionary<Value> {
private let name: String
private var dict = [String: Value]()
private let queue: DispatchQueue
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: String) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
}

View File

@ -9,11 +9,13 @@
import UIKit import UIKit
import Photos import Photos
import MobileCoreServices import MobileCoreServices
import PencilKit
enum CompositionAttachmentData { enum CompositionAttachmentData {
case asset(PHAsset) case asset(PHAsset)
case image(UIImage) case image(UIImage)
case video(URL) case video(URL)
case drawing(PKDrawing)
var type: AttachmentType { var type: AttachmentType {
switch self { switch self {
@ -23,6 +25,8 @@ enum CompositionAttachmentData {
return .image return .image
case .video(_): case .video(_):
return .video return .video
case .drawing(_):
return .image
} }
} }
@ -44,7 +48,7 @@ enum CompositionAttachmentData {
} }
} }
func getData(completion: @escaping (Data, String) -> Void) { func getData(completion: @escaping (_ data: Data, _ mimeType: String) -> Void) {
switch self { switch self {
case let .image(image): case let .image(image):
completion(image.pngData()!, "image/png") completion(image.pngData()!, "image/png")
@ -90,6 +94,10 @@ enum CompositionAttachmentData {
fatalError("failed to create export session") fatalError("failed to create export session")
} }
CompositionAttachmentData.exportVideoData(session: session, completion: completion) CompositionAttachmentData.exportVideoData(session: session, completion: completion)
case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(image.pngData()!, "image/png")
} }
} }
@ -138,6 +146,10 @@ extension CompositionAttachmentData: Codable {
try container.encode(image.pngData()!, forKey: .imageData) try container.encode(image.pngData()!, forKey: .imageData)
case .video(_): case .video(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded")) throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded"))
case let .drawing(drawing):
try container.encode("drawing", forKey: .type)
let drawingData = drawing.dataRepresentation()
try container.encode(drawingData, forKey: .drawing)
} }
} }
@ -156,6 +168,10 @@ extension CompositionAttachmentData: Codable {
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data") throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data")
} }
self = .image(image) self = .image(image)
case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing)
default: default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'") throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
} }
@ -166,6 +182,8 @@ extension CompositionAttachmentData: Codable {
case imageData case imageData
/// The local identifier of the PHAsset for this attachment /// The local identifier of the PHAsset for this attachment
case assetIdentifier case assetIdentifier
/// The PKDrawing object for this attachment.
case drawing
} }
} }
@ -178,6 +196,8 @@ extension CompositionAttachmentData: Equatable {
return a == b return a == b
case let (.video(a), .video(b)): case let (.video(a), .video(b)):
return a == b return a == b
case let (.drawing(a), .drawing(b)):
return a == b
default: default:
return false return false
} }

View File

@ -109,6 +109,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Called as the scene transitions from the foreground to the background. // Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information // Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state. // to restore the scene back to its current state.
try! scene.session.mastodonController?.persistentContainer.viewContext.save()
} }
func activateAccount(_ account: LocalData.UserAccountInfo) { func activateAccount(_ account: LocalData.UserAccountInfo) {

View File

@ -52,6 +52,9 @@ class AssetPreviewViewController: UIViewController {
default: default:
fatalError("asset mediaType must be image or video") fatalError("asset mediaType must be image or video")
} }
case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds)
showImage(image)
} }
} }

View File

@ -45,7 +45,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
let request = Client.getBookmarks() let request = Client.getBookmarks()
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() } guard case let .success(statuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: statuses) self.mastodonController.persistentContainer.addAll(statuses: statuses)
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
@ -87,7 +87,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
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.persistentContainer.addAll(statuses: newStatuses)
let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map { let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map {
IndexPath(row: $0, section: 0) IndexPath(row: $0, section: 0)
} }
@ -112,15 +112,15 @@ class BookmarksTableViewController: EnhancedTableViewController {
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let cellConfig = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration() let cellConfig = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else {
return cellConfig return cellConfig
} }
let unbookmarkAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Unbookmark", comment: "unbookmark action title")) { (action, view, completion) in let unbookmarkAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Unbookmark", comment: "unbookmark action title")) { (action, view, completion) in
let request = Status.unbookmark(status) let request = Status.unbookmark(status.id)
self.mastodonController.run(request) { (response) in self.mastodonController.run(request) { (response) in
guard case let .success(newStatus, _) = response else { fatalError() } guard case let .success(newStatus, _) = response else { fatalError() }
self.mastodonController.cache.add(status: newStatus) self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false)
self.statuses.remove(at: indexPath.row) self.statuses.remove(at: indexPath.row)
} }
} }
@ -138,13 +138,13 @@ class BookmarksTableViewController: EnhancedTableViewController {
} }
override func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] { override func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { return [] } guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { return [] }
return [ return [
UIAction(title: NSLocalizedString("Unbookmark", comment: "unbookmark action title"), image: UIImage(systemName: "bookmark.fill"), identifier: .init("unbookmark"), discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in UIAction(title: NSLocalizedString("Unbookmark", comment: "unbookmark action title"), image: UIImage(systemName: "bookmark.fill"), identifier: .init("unbookmark"), discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
let request = Status.unbookmark(status) let request = Status.unbookmark(status.id)
self.mastodonController.run(request) { (response) in self.mastodonController.run(request) { (response) in
guard case let .success(newStatus, _) = response else { fatalError() } guard case let .success(newStatus, _) = response else { fatalError() }
self.mastodonController.cache.add(status: newStatus) self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false)
self.statuses.remove(at: indexPath.row) self.statuses.remove(at: indexPath.row)
} }
}) })
@ -165,7 +165,7 @@ extension BookmarksTableViewController: StatusTableViewCellDelegate {
extension BookmarksTableViewController: UITableViewDataSourcePrefetching { 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.persistentContainer.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)
@ -175,7 +175,7 @@ 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.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.cancelWithoutCallback(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.cancelWithoutCallback(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import MobileCoreServices import MobileCoreServices
import PencilKit
protocol ComposeAttachmentsViewControllerDelegate: class { protocol ComposeAttachmentsViewControllerDelegate: class {
func composeSelectedAttachmentsDidChange() func composeSelectedAttachmentsDidChange()
@ -38,6 +39,8 @@ class ComposeAttachmentsViewController: UITableViewController {
} }
} }
private var currentlyEditedDrawingIndex: Int?
init(attachments: [CompositionAttachment], mastodonController: MastodonController) { init(attachments: [CompositionAttachment], mastodonController: MastodonController) {
self.attachments = attachments self.attachments = attachments
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -139,6 +142,23 @@ class ComposeAttachmentsViewController: UITableViewController {
} }
} }
func presentComposeDrawingViewController(editingAttachmentAt attachmentIndex: Int? = nil) {
let drawingVC: ComposeDrawingViewController
if let index = attachmentIndex,
case let .drawing(drawing) = attachments[index].data {
drawingVC = ComposeDrawingViewController(editing: drawing)
currentlyEditedDrawingIndex = index
} else {
drawingVC = ComposeDrawingViewController()
}
drawingVC.delegate = self
let nav = UINavigationController(rootViewController: drawingVC)
nav.modalPresentationStyle = .fullScreen
present(nav, animated: true)
}
func uploadAll(stepProgress: @escaping () -> Void, completion: @escaping (_ success: Bool, _ uploadedAttachments: [Attachment]) -> Void) { func uploadAll(stepProgress: @escaping () -> Void, completion: @escaping (_ success: Bool, _ uploadedAttachments: [Attachment]) -> Void) {
let group = DispatchGroup() let group = DispatchGroup()
@ -270,19 +290,49 @@ class ComposeAttachmentsViewController: UITableViewController {
} }
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard indexPath.section == 0 else { return nil } if indexPath.section == 0 {
let attachment = attachments[indexPath.row]
// cast to NSIndexPath because identifier needs to conform to NSCopying
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in
return AssetPreviewViewController(attachment: attachment.data)
}) { (_) -> UIMenu? in
var actions = [UIAction]()
let attachment = attachments[indexPath.row] switch attachment.data {
// cast to NSIndexPath because identifier needs to conform to NSCopying case .drawing(_):
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in actions.append(UIAction(title: "Edit Drawing", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
return AssetPreviewViewController(attachment: attachment.data) self.presentComposeDrawingViewController(editingAttachmentAt: indexPath.row)
}) { (_) -> UIMenu? in }))
default:
break
}
if actions.isEmpty {
return nil
} else {
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}
}
} else if indexPath.section == 1 {
guard isAddAttachmentsButtonEnabled() else {
return nil
}
// show context menu for drawing/file uploads
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
UIAction(title: "Draw Something", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.presentComposeDrawingViewController()
})
])
}
} else {
return nil return nil
} }
} }
private func targetedPreview(forConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { private func targetedPreview(forConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?, if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?,
indexPath.section == 0,
let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell { let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell {
let parameters = UIPreviewParameters() let parameters = UIPreviewParameters()
parameters.backgroundColor = .black parameters.backgroundColor = .black
@ -452,3 +502,26 @@ extension ComposeAttachmentsViewController: ComposeAttachmentTableViewCellDelega
delegate?.composeRequiresAttachmentDescriptionsDidChange() delegate?.composeRequiresAttachmentDescriptionsDidChange()
} }
} }
extension ComposeAttachmentsViewController: ComposeDrawingViewControllerDelegate {
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
dismiss(animated: true)
currentlyEditedDrawingIndex = nil
}
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
let newAttachment = CompositionAttachment(data: .drawing(drawing))
if let currentlyEditedDrawingIndex = currentlyEditedDrawingIndex {
attachments[currentlyEditedDrawingIndex] = newAttachment
tableView.reloadRows(at: [IndexPath(row: currentlyEditedDrawingIndex, section: 0)], with: .automatic)
} else {
attachments.append(newAttachment)
tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic)
updateHeightConstraint()
}
dismiss(animated: true)
currentlyEditedDrawingIndex = nil
}
}

View File

@ -0,0 +1,174 @@
//
// ComposeDrawingViewController.swift
// Tusker
//
// Created by Shadowfacts on 5/7/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import PencilKit
protocol ComposeDrawingViewControllerDelegate: class {
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController)
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing)
}
class ComposeDrawingViewController: UIViewController {
weak var delegate: ComposeDrawingViewControllerDelegate?
private(set) var canvasView: PKCanvasView!
private(set) var cancelBarButtonItem: UIBarButtonItem!
private(set) var undoBarButtonItem: UIBarButtonItem!
private(set) var redoBarButtonItem: UIBarButtonItem!
private var initialDrawing: PKDrawing?
init() {
super.init(nibName: nil, bundle: nil)
}
convenience init(editing initialDrawing: PKDrawing) {
self.init()
self.initialDrawing = initialDrawing
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .light
navigationItem.title = NSLocalizedString("Draw", comment: "compose drawing screen title")
cancelBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelPressed))
undoBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.left.circle"), style: .plain, target: self, action: #selector(undoPressed))
undoBarButtonItem.isEnabled = false
redoBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.right.circle"), style: .plain, target: self, action: #selector(redoPressed))
redoBarButtonItem.isEnabled = false
navigationItem.leftBarButtonItems = [cancelBarButtonItem]
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(savePressed))
canvasView = PKCanvasView()
if let initialDrawing = initialDrawing {
canvasView.drawing = initialDrawing
}
canvasView.delegate = self
canvasView.allowsFingerDrawing = true
canvasView.minimumZoomScale = 0.5
canvasView.maximumZoomScale = 2
canvasView.backgroundColor = .systemBackground
canvasView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(canvasView)
NSLayoutConstraint.activate([
canvasView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
canvasView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
canvasView.topAnchor.constraint(equalTo: view.topAnchor),
canvasView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let window = parent?.view.window, let toolPicker = PKToolPicker.shared(for: window) {
toolPicker.setVisible(true, forFirstResponder: canvasView)
toolPicker.addObserver(canvasView)
toolPicker.addObserver(self)
updateLayout(for: toolPicker)
canvasView.becomeFirstResponder()
// wait until the next run loop iteration so that the canvas view has become first responder and it's undo manager exists
DispatchQueue.main.async {
NotificationCenter.default.addObserver(self, selector: #selector(self.updateUndoRedoButtonState), name: .NSUndoManagerDidUndoChange, object: self.undoManager!)
NotificationCenter.default.addObserver(self, selector: #selector(self.updateUndoRedoButtonState), name: .NSUndoManagerDidRedoChange, object: self.undoManager!)
}
}
}
func updateLayout(for toolPicker: PKToolPicker) {
let obscuredFrame = toolPicker.frameObscured(in: view)
// if there is no obscured frame, the tool picker is floating
// which means we don't need to change inset or show undo/redo
if obscuredFrame.isNull {
navigationItem.leftBarButtonItems = [cancelBarButtonItem]
canvasView.contentInset = .zero
canvasView.automaticallyAdjustsScrollIndicatorInsets = true
} else {
navigationItem.leftBarButtonItems = [cancelBarButtonItem, undoBarButtonItem, redoBarButtonItem]
canvasView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: obscuredFrame.height, right: 0)
canvasView.automaticallyAdjustsScrollIndicatorInsets = false
// we don't care about the botom safe area inset becaused the tool picker obscured frame includes the safe area
canvasView.scrollIndicatorInsets = UIEdgeInsets(top: view.safeAreaInsets.top, left: view.safeAreaInsets.left, bottom: obscuredFrame.height, right: view.safeAreaInsets.right)
}
}
func updateContentSizeForDrawing() {
let overscrollAmount: CGFloat = 100
let drawingBounds = canvasView.drawing.bounds
let newSize: CGSize
if !drawingBounds.isNull {
let width = max(canvasView.bounds.width, (drawingBounds.maxX + overscrollAmount) * canvasView.zoomScale)
let height = max(canvasView.bounds.height, (drawingBounds.maxY + overscrollAmount) * canvasView.zoomScale)
newSize = CGSize(width: width, height: height)
} else {
newSize = canvasView.bounds.size
}
canvasView.contentSize = newSize
}
@objc func updateUndoRedoButtonState() {
undoBarButtonItem.isEnabled = undoManager!.canUndo
redoBarButtonItem.isEnabled = undoManager!.canRedo
}
// MARK: Interaction
@objc func cancelPressed() {
delegate?.composeDrawingViewControllerClose(self)
}
@objc func savePressed() {
delegate?.composeDrawingViewController(self, saveDrawing: canvasView.drawing)
}
@objc func undoPressed() {
undoManager!.undo()
}
@objc func redoPressed() {
undoManager!.redo()
}
}
extension ComposeDrawingViewController: PKCanvasViewDelegate {
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
if !undoManager!.isUndoing && !undoManager!.isRedoing {
updateUndoRedoButtonState()
}
updateContentSizeForDrawing()
}
}
extension ComposeDrawingViewController: PKToolPickerObserver {
func toolPickerFramesObscuredDidChange(_ toolPicker: PKToolPicker) {
updateLayout(for: toolPicker)
}
func toolPickerVisibilityDidChange(_ toolPicker: PKToolPicker) {
updateLayout(for: toolPicker)
}
}
extension ComposeDrawingViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return otherGestureRecognizer == canvasView.drawingGestureRecognizer
}
}

View File

@ -72,7 +72,7 @@ class ComposeViewController: UIViewController {
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.inReplyToID = inReplyToID self.inReplyToID = inReplyToID
if let inReplyToID = inReplyToID, let inReplyTo = mastodonController.cache.status(for: inReplyToID) { if let inReplyToID = inReplyToID, let inReplyTo = mastodonController.persistentContainer.status(for: inReplyToID) {
accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct } accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct }
} else { } else {
accountsToMention = [] accountsToMention = []
@ -164,17 +164,20 @@ class ComposeViewController: UIViewController {
} }
if let inReplyToID = inReplyToID { if let inReplyToID = inReplyToID {
if let status = mastodonController.cache.status(for: inReplyToID) { if let status = mastodonController.persistentContainer.status(for: inReplyToID) {
updateInReplyTo(inReplyTo: status) updateInReplyTo(inReplyTo: status)
} else { } else {
let loadingVC = LoadingViewController() let loadingVC = LoadingViewController()
embedChild(loadingVC) embedChild(loadingVC)
mastodonController.cache.status(for: inReplyToID) { (status) in let request = Client.getStatus(id: inReplyToID)
guard let status = status else { return } mastodonController.run(request) { (response) in
DispatchQueue.main.async { guard case let .success(status, _) = response else { return }
self.updateInReplyTo(inReplyTo: status) self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true) { (status) in
loadingVC.removeViewAndController() DispatchQueue.main.async {
self.updateInReplyTo(inReplyTo: status)
loadingVC.removeViewAndController()
}
} }
} }
} }
@ -186,7 +189,7 @@ class ComposeViewController: UIViewController {
} }
} }
func updateInReplyTo(inReplyTo: Status) { func updateInReplyTo(inReplyTo: StatusMO) {
visibility = inReplyTo.visibility visibility = inReplyTo.visibility
if Preferences.shared.contentWarningCopyMode == .doNotCopy { if Preferences.shared.contentWarningCopyMode == .doNotCopy {
contentWarningEnabled = false contentWarningEnabled = false
@ -213,7 +216,8 @@ class ComposeViewController: UIViewController {
replyAvatarImageViewTopConstraint!.isActive = true replyAvatarImageViewTopConstraint!.isActive = true
inReplyToContainer.isHidden = false 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) { override func viewWillAppear(_ animated: Bool) {
@ -469,7 +473,7 @@ class ComposeViewController: UIViewController {
self.mastodonController.run(request) { (response) in self.mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else { fatalError() } guard case let .success(status, _) = response else { fatalError() }
self.postedStatus = status self.postedStatus = status
self.mastodonController.cache.add(status: status) // self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true)
if let draft = self.currentDraft { if let draft = self.currentDraft {
DraftsManager.shared.remove(draft) DraftsManager.shared.remove(draft)
@ -480,8 +484,8 @@ class ComposeViewController: UIViewController {
self.dismiss(animated: true) self.dismiss(animated: true)
// todo: this doesn't work // todo: this doesn't work
let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController) // let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController)
self.show(conversationVC, sender: self) // self.show(conversationVC, sender: self)
self.xcbSession?.complete(with: .success, additionalData: [ self.xcbSession?.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString, "statusURL": status.url?.absoluteString,

View File

@ -42,6 +42,13 @@ class ConversationTableViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
mastodonController.persistentContainer.status(for: mainStatusID)?.decrementReferenceCount()
for (id, _) in statuses {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -58,32 +65,42 @@ class ConversationTableViewController: EnhancedTableViewController {
statuses = [(mainStatusID, mainStatusState)] statuses = [(mainStatusID, mainStatusState)]
guard let mainStatus = mastodonController.cache.status(for: mainStatusID) else { fatalError("Missing cached status \(mainStatusID)") } guard let mainStatus = self.mastodonController.persistentContainer.status(for: self.mainStatusID) else {
fatalError("Missing cached status \(self.mainStatusID)")
}
let mainStatusInReplyToID = mainStatus.inReplyToID
mainStatus.incrementReferenceCount()
let request = Status.getContext(mainStatus) let request = Status.getContext(mainStatusID)
mastodonController.run(request) { response in mastodonController.run(request) { response in
guard case let .success(context, _) = response else { fatalError() } guard case let .success(context, _) = response else { fatalError() }
let parents = self.getDirectParents(of: mainStatus, from: context.ancestors)
self.mastodonController.cache.addAll(statuses: parents) let parents = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors)
self.mastodonController.cache.addAll(statuses: context.descendants) let parentStatuses = context.ancestors.filter { parents.contains($0.id) }
self.statuses = parents.map { ($0.id, .unknown) } + self.statuses + context.descendants.map { ($0.id, .unknown) } self.mastodonController.persistentContainer.addAll(statuses: parentStatuses) {
let indexPath = IndexPath(row: parents.count, section: 0) self.mastodonController.persistentContainer.addAll(statuses: context.descendants) {
DispatchQueue.main.async { self.statuses = parents.map { ($0, .unknown) } + self.statuses + context.descendants.map { ($0.id, .unknown) }
self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false) let indexPath = IndexPath(row: parents.count, section: 0)
DispatchQueue.main.async {
self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}
}
} }
} }
} }
func getDirectParents(of status: Status, from statuses: [Status]) -> [Status] { func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
var statuses = statuses var statuses = statuses
var parents: [Status] = [] var parents = [String]()
var currentStatus: Status? = status
while currentStatus != nil { var parentID: String? = inReplyToID
guard let index = statuses.firstIndex(where: { $0.id == currentStatus!.inReplyToID }) else { break }
let parent = statuses.remove(at: index) while parentID != nil, let parentIndex = statuses.firstIndex(where: { $0.id == parentID }) {
parents.insert(parent, at: 0) let parentStatus = statuses.remove(at: parentIndex)
currentStatus = parent parents.insert(parentStatus.id, at: 0)
parentID = parentStatus.inReplyToID
} }
return parents return parents
} }
@ -169,7 +186,7 @@ extension ConversationTableViewController: StatusTableViewCellDelegate {
extension ConversationTableViewController: UITableViewDataSourcePrefetching { 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.persistentContainer.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)
@ -179,7 +196,7 @@ 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.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar) ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.cancelWithoutCallback(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)

View File

@ -80,7 +80,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
self.nextRange = pagination?.older self.nextRange = pagination?.older
self.mastodonController.cache.addAll(accounts: accounts) self.mastodonController.persistentContainer.addAll(accounts: accounts)
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.accounts]) snapshot.deleteSections([.accounts])

View File

@ -63,15 +63,13 @@ class NotificationsTableViewController: EnhancedTableViewController {
self.groups.append(contentsOf: groups) self.groups.append(contentsOf: groups)
self.mastodonController.cache.addAll(notifications: notifications)
self.mastodonController.cache.addAll(statuses: notifications.compactMap { $0.status })
self.mastodonController.cache.addAll(accounts: notifications.map { $0.account })
self.newer = pagination?.newer self.newer = pagination?.newer
self.older = pagination?.older self.older = pagination?.older
DispatchQueue.main.async { self.mastodonController.persistentContainer.addAll(notifications: notifications) {
self.tableView.reloadData() DispatchQueue.main.async {
self.tableView.reloadData()
}
} }
} }
} }
@ -92,7 +90,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
switch group.kind { switch group.kind {
case .mention: case .mention:
guard let notification = mastodonController.cache.notification(for: group.notificationIDs.first!), guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else { let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else {
fatalError() fatalError()
} }
@ -113,7 +111,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
return cell return cell
case .followRequest: case .followRequest:
guard let notification = mastodonController.cache.notification(for: group.notificationIDs.first!), guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: followRequestCell, for: indexPath) as? FollowRequestNotificationTableViewCell else { fatalError() } let cell = tableView.dequeueReusableCell(withIdentifier: followRequestCell, for: indexPath) as? FollowRequestNotificationTableViewCell else { fatalError() }
cell.delegate = self cell.delegate = self
cell.updateUI(notification: notification) cell.updateUI(notification: notification)
@ -143,15 +141,13 @@ class NotificationsTableViewController: EnhancedTableViewController {
} }
self.groups.append(contentsOf: groups) self.groups.append(contentsOf: groups)
self.mastodonController.cache.addAll(notifications: newNotifications)
self.mastodonController.cache.addAll(statuses: newNotifications.compactMap { $0.status })
self.mastodonController.cache.addAll(accounts: newNotifications.map { $0.account })
self.older = pagination?.older self.older = pagination?.older
DispatchQueue.main.async { self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
UIView.performWithoutAnimation { DispatchQueue.main.async {
self.tableView.insertRows(at: newIndexPaths, with: .automatic) UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
} }
} }
} }
@ -195,8 +191,8 @@ class NotificationsTableViewController: EnhancedTableViewController {
func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) { func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
let group = DispatchGroup() let group = DispatchGroup()
groups[indexPath.row].notificationIDs groups[indexPath.row].notifications
.map(Pachyderm.Notification.dismiss(id:)) .map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in .forEach { (request) in
group.enter() group.enter()
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
@ -221,26 +217,24 @@ class NotificationsTableViewController: EnhancedTableViewController {
self.groups.insert(contentsOf: groups, at: 0) self.groups.insert(contentsOf: groups, at: 0)
self.mastodonController.cache.addAll(notifications: newNotifications)
self.mastodonController.cache.addAll(statuses: newNotifications.compactMap { $0.status })
self.mastodonController.cache.addAll(accounts: newNotifications.map { $0.account })
if let newer = pagination?.newer { if let newer = pagination?.newer {
self.newer = newer self.newer = newer
} }
DispatchQueue.main.async { self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
let newIndexPaths = (0..<groups.count).map { DispatchQueue.main.async {
IndexPath(row: $0, section: 0) let newIndexPaths = (0..<groups.count).map {
} IndexPath(row: $0, section: 0)
UIView.performWithoutAnimation { }
self.tableView.insertRows(at: newIndexPaths, with: .automatic) UIView.performWithoutAnimation {
} 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)
self.tableView.scrollToRow(at: IndexPath(row: newNotifications.count, section: 0), at: .top, animated: false) self.tableView.scrollToRow(at: IndexPath(row: newNotifications.count, section: 0), at: .top, animated: false)
}
} }
} }
} }
@ -259,8 +253,8 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
extension NotificationsTableViewController: UITableViewDataSourcePrefetching { extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
for notificationID in groups[indexPath.row].notificationIDs { for notification in groups[indexPath.row].notifications {
guard let notification = mastodonController.cache.notification(for: notificationID) else { continue } // todo: this account object could be stale
_ = ImageCache.avatars.get(notification.account.avatar, completion: nil) _ = ImageCache.avatars.get(notification.account.avatar, completion: nil)
} }
} }
@ -268,8 +262,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
for notificationID in groups[indexPath.row].notificationIDs { for notification in groups[indexPath.row].notifications {
guard let notification = mastodonController.cache.notification(for: notificationID) else { continue }
ImageCache.avatars.cancelWithoutCallback(notification.account.avatar) ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
} }
} }

View File

@ -68,10 +68,13 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
let authCode = item.value else { return } let authCode = item.value else { return }
mastodonController.authorize(authorizationCode: authCode) { (accessToken) in mastodonController.authorize(authorizationCode: authCode) { (accessToken) in
mastodonController.getOwnAccount { (account) in let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken)
DispatchQueue.main.async { mastodonController.accountInfo = accountInfo
let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: account.username, accessToken: accessToken)
mastodonController.getOwnAccount { (account) in
LocalData.shared.setUsername(for: accountInfo, username: account.username)
DispatchQueue.main.async {
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
} }
} }

View File

@ -73,13 +73,14 @@ class ProfileTableViewController: EnhancedTableViewController {
tableView.prefetchDataSource = self tableView.prefetchDataSource = self
if let accountID = accountID { if let accountID = accountID {
if mastodonController.cache.account(for: accountID) != nil { if mastodonController.persistentContainer.account(for: accountID) != nil {
updateAccountUI() updateAccountUI()
} else { } else {
loadingVC = LoadingViewController() loadingVC = LoadingViewController()
embedChild(loadingVC!) embedChild(loadingVC!)
mastodonController.cache.account(for: accountID) { (account) in let request = Client.getAccount(id: accountID)
guard account != nil else { mastodonController.run(request) { (response) in
guard case let .success(account, _) = response else {
let alert = UIAlertController(title: "Something Went Wrong", message: "Couldn't load the selected account", preferredStyle: .alert) let alert = UIAlertController(title: "Something Went Wrong", message: "Couldn't load the selected account", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { (_) in alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { (_) in
self.navigationController!.popViewController(animated: true) self.navigationController!.popViewController(animated: true)
@ -89,9 +90,11 @@ class ProfileTableViewController: EnhancedTableViewController {
} }
return return
} }
DispatchQueue.main.async { self.mastodonController.persistentContainer.addOrUpdate(account: account) { (_) in
self.updateAccountUI() DispatchQueue.main.async {
self.tableView.reloadData() self.updateAccountUI()
self.tableView.reloadData()
}
} }
} }
} }
@ -112,23 +115,25 @@ class ProfileTableViewController: EnhancedTableViewController {
getStatuses(onlyPinned: true) { (response) in getStatuses(onlyPinned: true) { (response) in
guard case let .success(statuses, _) = response else { fatalError() } guard case let .success(statuses, _) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: statuses) self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.pinnedStatuses = statuses.map { ($0.id, .unknown) } self.pinnedStatuses = statuses.map { ($0.id, .unknown) }
}
} }
getStatuses() { response in getStatuses() { response in
guard case let .success(statuses, pagination) = response else { fatalError() } guard case let .success(statuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: statuses) self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.timelineSegments.append(statuses.map { ($0.id, .unknown) }) self.timelineSegments.append(statuses.map { ($0.id, .unknown) })
self.older = pagination?.older self.older = pagination?.older
self.newer = pagination?.newer self.newer = pagination?.newer
}
} }
} }
@objc func updateUIForPreferences() { @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 navigationItem.title = account.displayNameWithoutCustomEmoji
} }
@ -138,7 +143,7 @@ class ProfileTableViewController: EnhancedTableViewController {
} }
func sendMessageMentioning() { func sendMessageMentioning() {
guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") } guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
let vc = UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController)) let vc = UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController))
present(vc, animated: true) present(vc, animated: true)
} }
@ -152,7 +157,7 @@ class ProfileTableViewController: EnhancedTableViewController {
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 { if section == 0 {
return accountID == nil || mastodonController.cache.account(for: accountID) == nil ? 0 : 1 return accountID == nil || mastodonController.persistentContainer.account(for: accountID) == nil ? 0 : 1
} else if section == 1 { } else if section == 1 {
return pinnedStatuses.count return pinnedStatuses.count
} else { } else {
@ -187,16 +192,20 @@ class ProfileTableViewController: EnhancedTableViewController {
// MARK: - Table view delegate // MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// todo: if scrolling up, remove statuses at bottom like timeline VC
// load older statuses if at bottom
if timelineSegments.count > 0 && indexPath.section - 1 == timelineSegments.count && indexPath.row == timelineSegments[indexPath.section - 2].count - 1 { if timelineSegments.count > 0 && indexPath.section - 1 == timelineSegments.count && indexPath.row == timelineSegments[indexPath.section - 2].count - 1 {
guard let older = older else { return } guard let older = older else { return }
getStatuses(for: older) { response in getStatuses(for: older) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() } guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: newStatuses) self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
self.timelineSegments[indexPath.section - 2].append(contentsOf: newStatuses.map { ($0.id, .unknown) }) self.timelineSegments[indexPath.section - 2].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
self.older = pagination?.older self.older = pagination?.older
}
} }
} }
} }
@ -219,34 +228,35 @@ class ProfileTableViewController: EnhancedTableViewController {
getStatuses(for: newer) { response in getStatuses(for: newer) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() } guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: newStatuses) self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0) self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
if let newer = pagination?.newer { if let newer = pagination?.newer {
self.newer = newer self.newer = newer
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self.refreshControl?.endRefreshing() self.refreshControl?.endRefreshing()
}
} }
} }
getStatuses(onlyPinned: true) { (response) in getStatuses(onlyPinned: true) { (response) in
guard case let .success(newPinnedStatuses, _) = response else { fatalError() } guard case let .success(newPinnedStatuses, _) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: newPinnedStatuses) self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatuses) {
let oldPinnedStatuses = self.pinnedStatuses
let oldPinnedStatuses = self.pinnedStatuses var pinnedStatuses = [(id: String, state: StatusState)]()
var pinnedStatuses = [(id: String, state: StatusState)]() for status in newPinnedStatuses {
for status in newPinnedStatuses { let state: StatusState
let state: StatusState if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) { state = oldState
state = oldState } else {
} else { state = .unknown
state = .unknown }
pinnedStatuses.append((status.id, state))
} }
pinnedStatuses.append((status.id, state)) self.pinnedStatuses = pinnedStatuses
} }
self.pinnedStatuses = pinnedStatuses
} }
} }
@ -268,13 +278,12 @@ extension ProfileTableViewController: StatusTableViewCellDelegate {
extension ProfileTableViewController: ProfileHeaderTableViewCellDelegate { extension ProfileTableViewController: ProfileHeaderTableViewCellDelegate {
func showMoreOptions(cell: ProfileHeaderTableViewCell) { func showMoreOptions(cell: ProfileHeaderTableViewCell) {
let account = mastodonController.cache.account(for: accountID)! let account = mastodonController.persistentContainer.account(for: accountID)!
mastodonController.cache.relationship(for: account.id) { [weak self] (relationship) in
guard let self = self else { return }
let request = Client.getRelationships(accounts: [account.id])
mastodonController.run(request) { (response) in
var customActivities: [UIActivity] = [OpenInSafariActivity()] var customActivities: [UIActivity] = [OpenInSafariActivity()]
if let relationship = relationship { if case let .success(results, _) = response, let relationship = results.first {
let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity() let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity()
customActivities.insert(toggleFollowActivity, at: 0) customActivities.insert(toggleFollowActivity, at: 0)
} }
@ -293,7 +302,7 @@ extension ProfileTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
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.persistentContainer.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)
@ -304,7 +313,7 @@ extension ProfileTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
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.persistentContainer.status(for: statusID) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar) ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.cancelWithoutCallback(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)

View File

@ -136,7 +136,7 @@ class SearchResultsViewController: EnhancedTableViewController {
if self.onlySections.contains(.accounts) && !results.accounts.isEmpty { if self.onlySections.contains(.accounts) && !results.accounts.isEmpty {
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts) snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
self.mastodonController.cache.addAll(accounts: results.accounts) self.mastodonController.persistentContainer.addAll(accounts: results.accounts)
} }
if self.onlySections.contains(.hashtags) && !results.hashtags.isEmpty { if self.onlySections.contains(.hashtags) && !results.hashtags.isEmpty {
snapshot.appendSections([.hashtags]) snapshot.appendSections([.hashtags])
@ -145,8 +145,8 @@ class SearchResultsViewController: EnhancedTableViewController {
if self.onlySections.contains(.statuses) && !results.statuses.isEmpty { if self.onlySections.contains(.statuses) && !results.statuses.isEmpty {
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses) snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
self.mastodonController.cache.addAll(statuses: results.statuses) self.mastodonController.persistentContainer.addAll(statuses: results.statuses)
self.mastodonController.cache.addAll(accounts: results.statuses.map { $0.account }) self.mastodonController.persistentContainer.addAll(accounts: results.statuses.map { $0.account })
} }
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
} }

View File

@ -73,16 +73,16 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
if accountIDs == nil { if accountIDs == nil {
// account IDs haven't been set, so perform a request to load them // account IDs haven't been set, so perform a request to load them
guard let status = mastodonController.cache.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Missing cached status \(statusID)") fatalError("Missing cached status \(statusID)")
} }
tableView.tableFooterView = UIActivityIndicatorView(style: .large) tableView.tableFooterView = UIActivityIndicatorView(style: .large)
let request = actionType == .favorite ? Status.getFavourites(status) : Status.getReblogs(status) let request = actionType == .favorite ? Status.getFavourites(status.id) : Status.getReblogs(status.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(accounts, _) = response else { fatalError() } guard case let .success(accounts, _) = response else { fatalError() }
self.mastodonController.cache.addAll(accounts: accounts) self.mastodonController.persistentContainer.addAll(accounts: accounts)
DispatchQueue.main.async { DispatchQueue.main.async {
self.accountIDs = accounts.map { $0.id } self.accountIDs = accounts.map { $0.id }
self.tableView.tableFooterView = nil self.tableView.tableFooterView = nil

View File

@ -19,6 +19,9 @@ class TimelineTableViewController: EnhancedTableViewController {
var newer: RequestRange? var newer: RequestRange?
var older: RequestRange? var older: RequestRange?
private var prevScrollViewContentOffset: CGPoint?
private var scrollViewDirection: CGFloat = 0
init(for timeline: Timeline, mastodonController: MastodonController) { init(for timeline: Timeline, mastodonController: MastodonController) {
self.timeline = timeline self.timeline = timeline
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -38,6 +41,17 @@ class TimelineTableViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
// decrement reference counts of any statuses we still have
// if the app is currently being quit, this will not affect the persisted data because
// the persistent container would already have been saved in SceneDelegate.sceneDidEnterBackground(_:)
for segment in timelineSegments {
for (id, _) in segment {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
}
func statusID(for indexPath: IndexPath) -> String { func statusID(for indexPath: IndexPath) -> String {
return timelineSegments[indexPath.section][indexPath.row].id return timelineSegments[indexPath.section][indexPath.row].id
} }
@ -59,12 +73,14 @@ class TimelineTableViewController: EnhancedTableViewController {
let request = Client.getStatuses(timeline: timeline) let request = Client.getStatuses(timeline: timeline)
mastodonController.run(request) { response in mastodonController.run(request) { response in
guard case let .success(statuses, pagination) = response else { fatalError() } guard case let .success(statuses, pagination) = response else { fatalError() }
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.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.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.tableView.reloadData() DispatchQueue.main.async {
self.tableView.reloadData()
}
} }
} }
} }
@ -93,6 +109,59 @@ class TimelineTableViewController: EnhancedTableViewController {
// MARK: - Table view delegate // MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// when scrolling upwards, decrement reference counts for old statuses, if necessary
if scrollViewDirection < 0 {
if indexPath.section <= timelineSegments.count - 2 {
// decrement ref counts for all sections below the section below the current section
// (e.g., there exist sections 0, 1, 2 and we're currently scrolling upwards in section 0, we want to remove section 2)
// todo: this is in the hot path for scrolling, possibly move this to a background thread?
let sectionsToRemove = indexPath.section + 1..<timelineSegments.count
for section in sectionsToRemove {
for (id, _) in timelineSegments.remove(at: section) {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
// see below comment about DispatchQueue.main.async
DispatchQueue.main.async {
UIView.performWithoutAnimation {
tableView.deleteSections(IndexSet(sectionsToRemove), with: .none)
}
}
} else {
// we are scrolling in the last or second to last section
// grab the last section and, if there is more than one page in the last section
// (we never want to remove the only page in the last section unless there is another section between it and the user),
// remove the last page and decrement reference counts
let pageSize = 20 // todo: this should come from somewhere in Pachyderm
let lastSection = timelineSegments.last!
if lastSection.count > 2 * pageSize,
indexPath.row < lastSection.count - (2 * pageSize) {
// todo: this is in the hot path for scrolling, possibly move this to a background thread?
let statusesToRemove = lastSection[lastSection.count - pageSize..<lastSection.count]
for (id, _) in statusesToRemove {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
timelineSegments[timelineSegments.count - 1].removeLast(20)
let removedIndexPaths = (lastSection.count - 20..<lastSection.count).map { IndexPath(row: $0, section: timelineSegments.count - 1) }
// Removing this DispatchQueue.main.async call causes things to break when scrolling
// back down towards the removed rows. There would be a index out of bounds crash
// because, while we've already removed the statuses from our model, the table view doesn't seem to know that.
// It seems like tableView update calls made from inside tableView(_:willDisplay:forRowAt:) are silently ignored.
// Calling tableView.numberOfRows(inSection: 0) when trapped in the debugger after the aforementioned IOOB
// will produce an incorrect value (it will be some multiple of pageSize too high).
// Deferring the tableView update until the next runloop iteration seems to solve that.
DispatchQueue.main.async {
UIView.performWithoutAnimation {
tableView.deleteRows(at: removedIndexPaths, with: .none)
}
}
}
}
}
// load older statuses, if necessary
if indexPath.section == timelineSegments.count - 1, if indexPath.section == timelineSegments.count - 1,
indexPath.row == timelineSegments[indexPath.section].count - 1 { indexPath.row == timelineSegments[indexPath.section].count - 1 {
guard let older = older else { return } guard let older = older else { return }
@ -101,13 +170,14 @@ class TimelineTableViewController: EnhancedTableViewController {
mastodonController.run(request) { response in mastodonController.run(request) { response in
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)
let newRows = self.timelineSegments.last!.count..<(self.timelineSegments.last!.count + newStatuses.count) let newRows = self.timelineSegments.last!.count..<(self.timelineSegments.last!.count + newStatuses.count)
let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.timelineSegments.count - 1) } 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.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
UIView.performWithoutAnimation { DispatchQueue.main.async {
self.tableView.insertRows(at: newIndexPaths, with: .none) UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
}
} }
} }
} }
@ -133,29 +203,39 @@ class TimelineTableViewController: EnhancedTableViewController {
mastodonController.run(request) { response in mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() } guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.newer = pagination?.newer self.newer = pagination?.newer
self.mastodonController.cache.addAll(statuses: newStatuses)
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0) self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
if let newer = pagination?.newer { if let newer = pagination?.newer {
self.newer = newer self.newer = newer
} }
DispatchQueue.main.async { self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
let newIndexPaths = (0..<newStatuses.count).map { DispatchQueue.main.async {
IndexPath(row: $0, section: 0) let newIndexPaths = (0..<newStatuses.count).map {
} IndexPath(row: $0, section: 0)
UIView.performWithoutAnimation { }
self.tableView.insertRows(at: newIndexPaths, with: .automatic) UIView.performWithoutAnimation {
} 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)
self.tableView.scrollToRow(at: IndexPath(row: newStatuses.count, section: 0), at: .top, animated: false) self.tableView.scrollToRow(at: IndexPath(row: newStatuses.count, section: 0), at: .top, animated: false)
}
} }
} }
} }
// Mark: Scroll View Delegate
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let prev = prevScrollViewContentOffset {
scrollViewDirection = scrollView.contentOffset.y - prev.y
}
prevScrollViewContentOffset = scrollView.contentOffset
}
@objc func composePressed(_ sender: Any) { @objc func composePressed(_ sender: Any) {
compose() compose()
} }
@ -175,7 +255,7 @@ extension TimelineTableViewController: StatusTableViewCellDelegate {
extension TimelineTableViewController: UITableViewDataSourcePrefetching { 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.persistentContainer.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)
@ -185,7 +265,10 @@ 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 } // todo: this means when removing cells, we can't cancel prefetching
// is this an issue?
guard indexPath.section < timelineSegments.count, indexPath.row < timelineSegments[indexPath.section].count,
let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar) ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments { for attachment in status.attachments {
ImageCache.attachments.cancelWithoutCallback(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)

View File

@ -30,7 +30,7 @@ extension MenuPreviewProvider {
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIAction] { func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIAction] {
guard let mastodonController = mastodonController, guard let mastodonController = mastodonController,
let account = mastodonController.cache.account(for: accountID) else { return [] } let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
return [ return [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { (_) in createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { (_) in
self.navigationDelegate?.compose(mentioning: account.acct) self.navigationDelegate?.compose(mentioning: account.acct)
@ -61,7 +61,7 @@ extension MenuPreviewProvider {
func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIAction] { func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIAction] {
guard let mastodonController = mastodonController, guard let mastodonController = mastodonController,
let status = mastodonController.cache.status(for: statusID) else { return [] } let status = mastodonController.persistentContainer.status(for: statusID) else { return [] }
return [ return [
createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { (_) in createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { (_) in
self.navigationDelegate?.reply(to: statusID) self.navigationDelegate?.reply(to: statusID)

View File

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

View File

@ -203,16 +203,15 @@ extension TuskerNavigationDelegate where Self: UIViewController {
} }
private func moreOptions(forStatus statusID: String) -> UIActivityViewController { private func moreOptions(forStatus statusID: String) -> UIActivityViewController {
guard let status = apiController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID)") } guard let status = apiController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
guard let url = status.url else { fatalError("Missing url for status \(statusID)") } guard let url = status.url else { fatalError("Missing url for status \(statusID)") }
var customActivites: [UIActivity] = [OpenInSafariActivity()] var customActivites: [UIActivity] = [OpenInSafariActivity()]
if let bookmarked = status.bookmarked { let bookmarked = status.bookmarked ?? false
customActivites.insert(bookmarked ? UnbookmarkStatusActivity() : BookmarkStatusActivity(), at: 0) customActivites.insert(bookmarked ? UnbookmarkStatusActivity() : BookmarkStatusActivity(), at: 0)
}
if status.account == apiController.account, if status.account.id == apiController.account.id {
let pinned = status.pinned { let pinned = status.pinned ?? false
customActivites.insert(pinned ? UnpinStatusActivity() : PinStatusActivity(), at: 1) customActivites.insert(pinned ? UnpinStatusActivity() : PinStatusActivity(), at: 1)
} }
@ -222,7 +221,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
} }
private func moreOptions(forAccount accountID: String) -> UIActivityViewController { private func moreOptions(forAccount accountID: String) -> UIActivityViewController {
guard let account = apiController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } guard let account = apiController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") }
return moreOptions(forURL: account.url) return moreOptions(forURL: account.url)
} }

View File

@ -34,7 +34,7 @@ class AccountTableViewCell: UITableViewCell {
@objc func updateUIForPrefrences() { @objc func updateUIForPrefrences() {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
guard let account = mastodonController.cache.account(for: accountID) else { guard let account = mastodonController.persistentContainer.account(for: accountID) else {
fatalError("Missing cached account \(accountID!)") fatalError("Missing cached account \(accountID!)")
} }
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
@ -42,7 +42,7 @@ class AccountTableViewCell: UITableViewCell {
func updateUI(accountID: String) { func updateUI(accountID: String) {
self.accountID = accountID self.accountID = accountID
guard let account = mastodonController.cache.account(for: accountID) else { guard let account = mastodonController.persistentContainer.account(for: accountID) else {
fatalError("Missing cached account \(accountID)") fatalError("Missing cached account \(accountID)")
} }

View File

@ -7,7 +7,6 @@
// //
import UIKit import UIKit
//import Combine
import Photos import Photos
import AVFoundation import AVFoundation
@ -42,6 +41,8 @@ class ComposeAttachmentTableViewCell: UITableViewCell {
descriptionTextView.text = attachment.attachmentDescription descriptionTextView.text = attachment.attachmentDescription
updateDescriptionPlaceholderLabel() updateDescriptionPlaceholderLabel()
assetImageView.contentMode = .scaleAspectFill
assetImageView.backgroundColor = .secondarySystemBackground
switch attachment.data { switch attachment.data {
case let .image(image): case let .image(image):
assetImageView.image = image assetImageView.image = image
@ -57,6 +58,10 @@ class ComposeAttachmentTableViewCell: UITableViewCell {
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
assetImageView.image = UIImage(cgImage: cgImage) assetImageView.image = UIImage(cgImage: cgImage)
} }
case let .drawing(drawing):
assetImageView.image = drawing.imageInLightMode(from: drawing.bounds)
assetImageView.contentMode = .scaleAspectFit
assetImageView.backgroundColor = .white
} }
} }

View File

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

View File

@ -39,7 +39,7 @@ class ComposeStatusReplyView: UIView {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
} }
func updateUI(for status: Status) { func updateUI(for status: StatusMO) {
displayNameLabel.updateForAccountDisplayName(account: status.account) displayNameLabel.updateForAccountDisplayName(account: status.account)
usernameLabel.text = "@\(status.account.acct)" usernameLabel.text = "@\(status.account.acct)"
statusContentTextView.overrideMastodonController = mastodonController statusContentTextView.overrideMastodonController = mastodonController

View File

@ -171,8 +171,13 @@ class ContentTextView: LinkTextView {
} }
} }
// only handles link taps via the gesture recognizer which is used when selection is disabled
@objc func textTapped(_ recognizer: UITapGestureRecognizer) { @objc func textTapped(_ recognizer: UITapGestureRecognizer) {
// if there currently is a selection, deselct it on single-tap
if selectedRange.length > 0 {
// location doesn't matter since we are non-editable and the cursor isn't visible
selectedRange = NSRange(location: 0, length: 0)
}
let location = recognizer.location(in: self) let location = recognizer.location(in: self)
if let (link, range) = getLinkAtPoint(location) { if let (link, range) = getLinkAtPoint(location) {
let text = (self.text as NSString).substring(with: range) let text = (self.text as NSString).substring(with: range)

View File

@ -34,6 +34,9 @@ class DraftTableViewCell: UITableViewCell {
attachmentsStackView.addArrangedSubview(imageView) attachmentsStackView.addArrangedSubview(imageView)
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
imageView.backgroundColor = .secondarySystemBackground
imageView.contentMode = .scaleAspectFill
switch attachment.data { switch attachment.data {
case let .asset(asset): case let .asset(asset):
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
@ -44,6 +47,10 @@ class DraftTableViewCell: UITableViewCell {
case .video(_): case .video(_):
// videos aren't saved to drafts, so this is unreachable // videos aren't saved to drafts, so this is unreachable
return return
case let .drawing(drawing):
imageView.image = drawing.imageInLightMode(from: drawing.bounds)
imageView.backgroundColor = .white
imageView.contentMode = .scaleAspectFit
} }
} }
} }

View File

@ -83,6 +83,15 @@ class EmojiLabel: UILabel {
extension EmojiLabel { extension EmojiLabel {
func updateForAccountDisplayName(account: Account) { 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 { if Preferences.shared.hideCustomEmojiInUsernames {
self.text = account.displayNameWithoutCustomEmoji self.text = account.displayNameWithoutCustomEmoji
self.removeEmojis() self.removeEmojis()

View File

@ -39,7 +39,8 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
} }
@objc func updateUIForPreferences() { @objc func updateUIForPreferences() {
let people = group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account } // todo: is this compactMap necessary?
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
updateActionLabel(people: people) updateActionLabel(people: people)
for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews { for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews {
@ -53,7 +54,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
} }
self.group = group self.group = group
guard let firstNotification = mastodonController.cache.notification(for: group.notificationIDs.first!) else { fatalError() } guard let firstNotification = group.notifications.first else { fatalError() }
let status = firstNotification.status! let status = firstNotification.status!
self.statusID = status.id self.statusID = status.id
@ -68,7 +69,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
fatalError() fatalError()
} }
let people = group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account } let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
actionAvatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } actionAvatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
var imageViews = [UIImageView]() var imageViews = [UIImageView]()
@ -107,8 +108,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
} }
func updateTimestamp() { func updateTimestamp() {
guard let id = group.notificationIDs.first, guard let notification = group.notifications.first else {
let notification = mastodonController.cache.notification(for: id) else {
fatalError("Missing cached notification") fatalError("Missing cached notification")
} }
@ -135,7 +135,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
} }
} }
func updateActionLabel(people: [Account]) { func updateActionLabel(people: [AccountMO]) {
let verb: String let verb: String
switch group.kind { switch group.kind {
case .favourite: case .favourite:
@ -147,13 +147,14 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
} }
let peopleStr: String let peopleStr: String
// todo: figure out how to localize this // todo: figure out how to localize this
// todo: update to use managed objects
switch people.count { switch people.count {
case 1: case 1:
peopleStr = people.first!.displayOrUserName peopleStr = people.first!.displayName
case 2: case 2:
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName peopleStr = people.first!.displayName + " and " + people.last!.displayName
default: 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)" actionLabel.text = "\(verb) by \(peopleStr)"
} }
@ -171,7 +172,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
extension ActionNotificationGroupTableViewCell: SelectableTableViewCell { extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
func didSelectCell() { func didSelectCell() {
guard let delegate = delegate else { return } guard let delegate = delegate else { return }
let notifications = group.notificationIDs.compactMap(mastodonController.cache.notification(for:)) let notifications = group.notifications
let accountIDs = notifications.map { $0.account.id } let accountIDs = notifications.map { $0.account.id }
let action: StatusActionAccountListTableViewController.ActionType let action: StatusActionAccountListTableViewController.ActionType
switch notifications.first!.kind { switch notifications.first!.kind {
@ -192,7 +193,7 @@ extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return (content: { return (content: {
let notifications = self.group.notificationIDs.compactMap(self.mastodonController.cache.notification(for:)) let notifications = self.group.notifications
let accountIDs = notifications.map { $0.account.id } let accountIDs = notifications.map { $0.account.id }
let action: StatusActionAccountListTableViewController.ActionType let action: StatusActionAccountListTableViewController.ActionType
switch notifications.first!.kind { switch notifications.first!.kind {

View File

@ -34,7 +34,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
} }
@objc func updateUIForPreferences() { @objc func updateUIForPreferences() {
let people = group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account } let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
updateActionLabel(people: people) updateActionLabel(people: people)
for case let imageView as UIImageView in avatarStackView.arrangedSubviews { for case let imageView as UIImageView in avatarStackView.arrangedSubviews {
@ -45,7 +45,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
func updateUI(group: NotificationGroup) { func updateUI(group: NotificationGroup) {
self.group = group self.group = group
let people = group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account } let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
updateActionLabel(people: people) updateActionLabel(people: people)
updateTimestamp() updateTimestamp()
@ -71,24 +71,24 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
} }
} }
func updateActionLabel(people: [Account]) { func updateActionLabel(people: [AccountMO]) {
// todo: custom emoji in people display names
// todo: figure out how to localize this // todo: figure out how to localize this
let peopleStr: String let peopleStr: String
switch people.count { switch people.count {
case 1: case 1:
peopleStr = people.first!.displayOrUserName peopleStr = people.first!.displayName
case 2: case 2:
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName peopleStr = people.first!.displayName + " and " + people.last!.displayName
default: 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)" actionLabel.text = "Followed by \(peopleStr)"
} }
func updateTimestamp() { func updateTimestamp() {
guard let id = group.notificationIDs.first, guard let notification = group.notifications.first else {
let notification = mastodonController.cache.notification(for: id) else {
fatalError("Missing cached notification") fatalError("Missing cached notification")
} }
@ -127,14 +127,14 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
extension FollowNotificationGroupTableViewCell: SelectableTableViewCell { extension FollowNotificationGroupTableViewCell: SelectableTableViewCell {
func didSelectCell() { func didSelectCell() {
let people = group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account.id } let accountIDs = group.notifications.map { $0.account.id }
switch people.count { switch accountIDs.count {
case 0: case 0:
return return
case 1: case 1:
delegate?.selected(account: people.first!) delegate?.selected(account: accountIDs.first!)
default: default:
delegate?.showFollowedByList(accountIDs: people) delegate?.showFollowedByList(accountIDs: accountIDs)
} }
} }
} }
@ -144,7 +144,7 @@ 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 }
let accountIDs = self.group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account.id } let accountIDs = self.group.notifications.map { $0.account.id }
return (content: { return (content: {
if accountIDs.count == 1 { if accountIDs.count == 1 {
return ProfileTableViewController(accountID: accountIDs.first!, mastodonController: mastodonController) return ProfileTableViewController(accountID: accountIDs.first!, mastodonController: mastodonController)

View File

@ -52,12 +52,13 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
} }
func updateUI(account: Account) { func updateUI(account: Account) {
// todo: update to use managed objects
self.account = account self.account = account
if Preferences.shared.hideCustomEmojiInUsernames { if Preferences.shared.hideCustomEmojiInUsernames {
actionLabel.text = "Request to follow from \(account.displayNameWithoutCustomEmoji)" actionLabel.text = "Request to follow from \(account.displayName)"
actionLabel.removeEmojis() actionLabel.removeEmojis()
} else { } else {
actionLabel.text = "Request to follow from \(account.displayOrUserName)" actionLabel.text = "Request to follow from \(account.displayName)"
actionLabel.setEmojis(account.emojis, identifier: account.id) actionLabel.setEmojis(account.emojis, identifier: account.id)
} }
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
@ -108,8 +109,7 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
@IBAction func rejectButtonPressed() { @IBAction func rejectButtonPressed() {
let request = Account.rejectFollowRequest(account) let request = Account.rejectFollowRequest(account)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(relationship, _) = response else { fatalError() } guard case .success(_, _) = response else { fatalError() }
self.mastodonController.cache.add(relationship: relationship)
DispatchQueue.main.async { DispatchQueue.main.async {
UINotificationFeedbackGenerator().notificationOccurred(.success) UINotificationFeedbackGenerator().notificationOccurred(.success)
self.actionButtonsStackView.isHidden = true self.actionButtonsStackView.isHidden = true
@ -125,8 +125,7 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
@IBAction func acceptButtonPressed() { @IBAction func acceptButtonPressed() {
let request = Account.authorizeFollowRequest(account) let request = Account.authorizeFollowRequest(account)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(relationship, _) = response else { fatalError() } guard case .success(_, _) = response else { fatalError() }
self.mastodonController.cache.add(relationship: relationship)
DispatchQueue.main.async { DispatchQueue.main.async {
UINotificationFeedbackGenerator().notificationOccurred(.success) UINotificationFeedbackGenerator().notificationOccurred(.success)
self.actionButtonsStackView.isHidden = true self.actionButtonsStackView.isHidden = true

View File

@ -63,7 +63,7 @@ class ProfileHeaderTableViewCell: UITableViewCell {
guard accountID != self.accountID else { return } guard accountID != self.accountID else { return }
self.accountID = accountID self.accountID = accountID
guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") }
updateUIForPreferences() updateUIForPreferences()
@ -88,55 +88,50 @@ class ProfileHeaderTableViewCell: UITableViewCell {
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis) noteTextView.setEmojis(account.emojis)
// don't show relationship label for the user's own account
if accountID != mastodonController.account.id { if accountID != mastodonController.account.id {
// don't show relationship label for the user's own account let request = Client.getRelationships(accounts: [accountID])
if let relationship = mastodonController.cache.relationship(for: accountID) { mastodonController.run(request) { (response) in
followsYouLabel.isHidden = !relationship.followedBy if case let .success(results, _) = response, let relationship = results.first {
} else {
mastodonController.cache.relationship(for: accountID) { relationship in
DispatchQueue.main.async { DispatchQueue.main.async {
self.followsYouLabel.isHidden = !(relationship?.followedBy ?? false) self.followsYouLabel.isHidden = !relationship.followedBy
} }
} }
} }
} }
if let fields = account.fields, !fields.isEmpty { fieldsStackView.isHidden = account.fields.isEmpty
fieldsStackView.isHidden = false
fieldsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } fieldsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for field in fields { for field in account.fields {
let nameLabel = UILabel() let nameLabel = UILabel()
nameLabel.text = field.name nameLabel.text = field.name
nameLabel.font = .boldSystemFont(ofSize: 17) nameLabel.font = .boldSystemFont(ofSize: 17)
nameLabel.textAlignment = .right nameLabel.textAlignment = .right
nameLabel.numberOfLines = 0 nameLabel.numberOfLines = 0
fieldNamesStackView.addArrangedSubview(nameLabel) fieldNamesStackView.addArrangedSubview(nameLabel)
let valueTextView = ContentTextView() let valueTextView = ContentTextView()
valueTextView.isSelectable = false valueTextView.isSelectable = false
valueTextView.font = .systemFont(ofSize: 17) valueTextView.font = .systemFont(ofSize: 17)
valueTextView.setTextFromHtml(field.value) valueTextView.setTextFromHtml(field.value)
valueTextView.setEmojis(account.emojis) valueTextView.setEmojis(account.emojis)
valueTextView.textAlignment = .left valueTextView.textAlignment = .left
valueTextView.awakeFromNib() valueTextView.awakeFromNib()
valueTextView.navigationDelegate = delegate valueTextView.navigationDelegate = delegate
fieldValuesStack.addArrangedSubview(valueTextView) fieldValuesStack.addArrangedSubview(valueTextView)
}
} else {
fieldsStackView.isHidden = true
} }
if accountUpdater == nil { if accountUpdater == nil {
accountUpdater = mastodonController.cache.accountSubject accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0.id == self.accountID } .filter { [unowned self] in $0 == self.accountID }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateUI(for: $0.id) } .sink { [unowned self] in self.updateUI(for: $0) }
} }
} }
@objc func updateUIForPreferences() { @objc func updateUIForPreferences() {
guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") } guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
@ -153,12 +148,12 @@ class ProfileHeaderTableViewCell: UITableViewCell {
} }
@objc func avatarPressed() { @objc func avatarPressed() {
guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") } guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
delegate?.showLoadingLargeImage(url: account.avatar, cache: .avatars, description: nil, animatingFrom: avatarImageView) delegate?.showLoadingLargeImage(url: account.avatar, cache: .avatars, description: nil, animatingFrom: avatarImageView)
} }
@objc func headerPressed() { @objc func headerPressed() {
guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") } guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
delegate?.showLoadingLargeImage(url: account.header, cache: .headers, description: nil, animatingFrom: headerImageView) delegate?.showLoadingLargeImage(url: account.header, cache: .headers, description: nil, animatingFrom: headerImageView)
} }

View File

@ -69,11 +69,6 @@ class BaseStatusTableViewCell: UITableViewCell {
private var statusUpdater: Cancellable? private var statusUpdater: Cancellable?
private var accountUpdater: Cancellable? private var accountUpdater: Cancellable?
deinit {
statusUpdater?.cancel()
accountUpdater?.cancel()
}
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -96,24 +91,32 @@ class BaseStatusTableViewCell: UITableViewCell {
open func createObserversIfNecessary() { open func createObserversIfNecessary() {
if statusUpdater == nil { if statusUpdater == nil {
statusUpdater = mastodonController.cache.statusSubject statusUpdater = mastodonController.persistentContainer.statusSubject
.filter { [unowned self] in $0.id == self.statusID } .filter { [unowned self] in $0 == self.statusID }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateStatusState(status: $0) } .sink { [unowned self] in
if let status = self.mastodonController.persistentContainer.status(for: $0) {
self.updateStatusState(status: status)
}
}
} }
if accountUpdater == nil { if accountUpdater == nil {
accountUpdater = mastodonController.cache.accountSubject accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0.id == self.accountID } .filter { [unowned self] in $0 == self.accountID }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateUI(account: $0) } .sink { [unowned self] in
if let account = self.mastodonController.persistentContainer.account(for: $0) {
self.updateUI(account: account)
}
}
} }
} }
func updateUI(statusID: String, state: StatusState) { func updateUI(statusID: String, state: StatusState) {
createObserversIfNecessary() createObserversIfNecessary()
guard let status = mastodonController.cache.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Missing cached status") fatalError("Missing cached status")
} }
self.statusID = statusID self.statusID = statusID
@ -161,9 +164,9 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
} }
func updateStatusState(status: Status) { func updateStatusState(status: StatusMO) {
favorited = status.favourited ?? false favorited = status.favourited
reblogged = status.reblogged ?? false reblogged = status.reblogged
if favorited { if favorited {
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label") favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
@ -177,22 +180,22 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
} }
func updateUI(account: Account) { func updateUI(account: AccountMO) {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
avatarImageView.image = nil avatarImageView.image = nil
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in 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 { DispatchQueue.main.async {
guard let self = self, let data = data, self.accountID == account.id else { return }
self.avatarImageView.image = UIImage(data: data) self.avatarImageView.image = UIImage(data: data)
} }
} }
} }
@objc func updateUIForPreferences() { @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) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account) 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() { override func prepareForReuse() {
@ -248,18 +251,18 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
@IBAction func favoritePressed() { @IBAction func favoritePressed() {
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 oldValue = favorited let oldValue = favorited
favorited = !favorited favorited = !favorited
let realStatus: Status = status.reblog ?? status let realStatus = status.reblog ?? status
let request = (favorited ? Status.favourite : Status.unfavourite)(realStatus) let request = (favorited ? Status.favourite : Status.unfavourite)(realStatus.id)
mastodonController.run(request) { response in mastodonController.run(request) { response in
DispatchQueue.main.async { DispatchQueue.main.async {
if case let .success(newStatus, _) = response { if case let .success(newStatus, _) = response {
self.favorited = newStatus.favourited ?? false self.favorited = newStatus.favourited
self.mastodonController.cache.add(status: newStatus) self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false)
UIImpactFeedbackGenerator(style: .light).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else { } else {
self.favorited = oldValue self.favorited = oldValue
@ -273,18 +276,18 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
@IBAction func reblogPressed() { @IBAction func reblogPressed() {
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 oldValue = reblogged let oldValue = reblogged
reblogged = !reblogged reblogged = !reblogged
let realStatus: Status = status.reblog ?? status let realStatus = status.reblog ?? status
let request = (reblogged ? Status.reblog : Status.unreblog)(realStatus) let request = (reblogged ? Status.reblog : Status.unreblog)(realStatus.id)
mastodonController.run(request) { response in mastodonController.run(request) { response in
DispatchQueue.main.async { DispatchQueue.main.async {
if case let .success(newStatus, _) = response { if case let .success(newStatus, _) = response {
self.reblogged = newStatus.reblogged ?? false self.reblogged = newStatus.reblogged
self.mastodonController.cache.add(status: newStatus) self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false)
UIImpactFeedbackGenerator(style: .light).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else { } else {
self.reblogged = oldValue self.reblogged = oldValue
@ -311,7 +314,7 @@ class BaseStatusTableViewCell: UITableViewCell {
extension BaseStatusTableViewCell: AttachmentViewDelegate { extension BaseStatusTableViewCell: AttachmentViewDelegate {
func attachmentViewGallery(startingAt index: Int) -> UIViewController { 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:)) let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
return delegate!.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index) return delegate!.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
} }

View File

@ -40,16 +40,16 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
override func updateUI(statusID: String, state: StatusState) { override func updateUI(statusID: String, state: StatusState) {
super.updateUI(statusID: statusID, state: state) super.updateUI(statusID: statusID, state: state)
guard let status = mastodonController.cache.status(for: statusID) else { fatalError() } guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() }
var timestampAndClientText = ConversationMainStatusTableViewCell.dateFormatter.string(from: status.createdAt) var timestampAndClientText = ConversationMainStatusTableViewCell.dateFormatter.string(from: status.createdAt)
if let application = status.application { if let application = status.applicationName {
timestampAndClientText += "\(application.name)" timestampAndClientText += "\(application)"
} }
timestampAndClientLabel.text = timestampAndClientText timestampAndClientLabel.text = timestampAndClientText
} }
override func updateStatusState(status: Status) { override func updateStatusState(status: StatusMO) {
super.updateStatusState(status: status) super.updateStatusState(status: status)
// todo: localize me // todo: localize me
@ -57,7 +57,7 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
totalReblogsButton.setTitle("\(status.reblogsCount) Reblog\(status.reblogsCount == 1 ? "" : "s")", for: .normal) 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) super.updateUI(account: account)
profileAccessibilityElement.accessibilityLabel = account.displayNameWithoutCustomEmoji profileAccessibilityElement.accessibilityLabel = account.displayNameWithoutCustomEmoji

View File

@ -48,19 +48,22 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
super.createObserversIfNecessary() super.createObserversIfNecessary()
if rebloggerAccountUpdater == nil { if rebloggerAccountUpdater == nil {
rebloggerAccountUpdater = mastodonController.cache.accountSubject rebloggerAccountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0.id == self.rebloggerID } .filter { [unowned self] in $0 == self.rebloggerID }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateRebloggerLabel(reblogger: $0) } .sink { [unowned self] in
if let reblogger = self.mastodonController.persistentContainer.account(for: $0) {
self.updateRebloggerLabel(reblogger: reblogger)
}
}
} }
} }
override func updateUI(statusID: String, state: StatusState) { 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 let realStatusID: String
if let rebloggedStatusID = status.reblog?.id, if let rebloggedStatus = status.reblog {
let rebloggedStatus = mastodonController.cache.status(for: rebloggedStatusID) {
reblogStatusID = statusID reblogStatusID = statusID
rebloggerID = status.account.id rebloggerID = status.account.id
status = rebloggedStatus status = rebloggedStatus
@ -85,12 +88,12 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
@objc override func updateUIForPreferences() { @objc override func updateUIForPreferences() {
super.updateUIForPreferences() super.updateUIForPreferences()
if let rebloggerID = rebloggerID, if let rebloggerID = rebloggerID,
let reblogger = mastodonController.cache.account(for: rebloggerID) { let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger) updateRebloggerLabel(reblogger: reblogger)
} }
} }
private func updateRebloggerLabel(reblogger: Account) { private func updateRebloggerLabel(reblogger: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames { if Preferences.shared.hideCustomEmojiInUsernames {
reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)" reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)"
reblogLabel.removeEmojis() reblogLabel.removeEmojis()
@ -104,7 +107,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 // 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 // so we bail out immediately, since there's nothing to update
guard let mastodonController = mastodonController else { return } 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.text = status.createdAt.timeAgoString()
timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date()) timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date())
@ -133,7 +136,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
func reply() { func reply() {
if Preferences.shared.mentionReblogger, if Preferences.shared.mentionReblogger,
let rebloggerID = rebloggerID, let rebloggerID = rebloggerID,
let rebloggerAccount = mastodonController.cache.account(for: rebloggerID) { let rebloggerAccount = mastodonController.persistentContainer.account(for: rebloggerID) {
delegate?.reply(to: statusID, mentioningAcct: rebloggerAccount.acct) delegate?.reply(to: statusID, mentioningAcct: rebloggerAccount.acct)
} else { } else {
delegate?.reply(to: statusID) delegate?.reply(to: statusID)
@ -176,19 +179,19 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
func leadingSwipeActionsConfiguration() -> UISwipeActionsConfiguration? { func leadingSwipeActionsConfiguration() -> UISwipeActionsConfiguration? {
guard let mastodonController = mastodonController else { return nil } guard let mastodonController = mastodonController else { return nil }
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 favoriteTitle: String let favoriteTitle: String
let favoriteRequest: Request<Status> let favoriteRequest: Request<Status>
let favoriteColor: UIColor let favoriteColor: UIColor
if status.favourited ?? false { if status.favourited {
favoriteTitle = "Unfavorite" favoriteTitle = "Unfavorite"
favoriteRequest = Status.unfavourite(status) favoriteRequest = Status.unfavourite(status.id)
favoriteColor = UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) favoriteColor = UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1)
} else { } else {
favoriteTitle = "Favorite" favoriteTitle = "Favorite"
favoriteRequest = Status.favourite(status) favoriteRequest = Status.favourite(status.id)
favoriteColor = UIColor(displayP3Red: 1, green: 204/255, blue: 0, alpha: 1) favoriteColor = UIColor(displayP3Red: 1, green: 204/255, blue: 0, alpha: 1)
} }
let favorite = UIContextualAction(style: .normal, title: favoriteTitle) { (action, view, completion) in let favorite = UIContextualAction(style: .normal, title: favoriteTitle) { (action, view, completion) in
@ -199,7 +202,7 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
return return
} }
completion(true) completion(true)
mastodonController.cache.add(status: status) mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} }
}) })
} }
@ -209,13 +212,13 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
let reblogTitle: String let reblogTitle: String
let reblogRequest: Request<Status> let reblogRequest: Request<Status>
let reblogColor: UIColor let reblogColor: UIColor
if status.reblogged ?? false { if status.reblogged {
reblogTitle = "Unreblog" reblogTitle = "Unreblog"
reblogRequest = Status.unreblog(status) reblogRequest = Status.unreblog(status.id)
reblogColor = UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) reblogColor = UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1)
} else { } else {
reblogTitle = "Reblog" reblogTitle = "Reblog"
reblogRequest = Status.reblog(status) reblogRequest = Status.reblog(status.id)
reblogColor = tintColor reblogColor = tintColor
} }
let reblog = UIContextualAction(style: .normal, title: reblogTitle) { (action, view, completion) in let reblog = UIContextualAction(style: .normal, title: reblogTitle) { (action, view, completion) in
@ -226,7 +229,7 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
return return
} }
completion(true) completion(true)
mastodonController.cache.add(status: status) mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} }
}) })
} }

View File

@ -15,7 +15,7 @@ class StatusContentTextView: ContentTextView {
didSet { didSet {
guard let statusID = statusID else { return } guard let statusID = statusID else { return }
guard let mastodonController = mastodonController, 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)") fatalError("Can't set StatusContentTextView text without cached status for \(statusID)")
} }
setTextFromHtml(status.content) setTextFromHtml(status.content)
@ -27,7 +27,7 @@ class StatusContentTextView: ContentTextView {
let mention: Mention? let mention: Mention?
if let statusID = statusID, if let statusID = statusID,
let mastodonController = mastodonController, let mastodonController = mastodonController,
let status = mastodonController.cache.status(for: statusID) { let status = mastodonController.persistentContainer.status(for: statusID) {
mention = status.mentions.first { (mention) in mention = status.mentions.first { (mention) in
// Mastodon and Pleroma include the @ in the <a> text, GNU Social does not // Mastodon and Pleroma include the @ in the <a> text, GNU Social does not
(text.dropFirst() == mention.username || text == mention.username) && url.host == mention.url.host (text.dropFirst() == mention.username || text == mention.username) && url.host == mention.url.host
@ -42,7 +42,7 @@ class StatusContentTextView: ContentTextView {
let hashtag: Hashtag? let hashtag: Hashtag?
if let statusID = statusID, if let statusID = statusID,
let mastodonController = mastodonController, let mastodonController = mastodonController,
let status = mastodonController.cache.status(for: statusID) { let status = mastodonController.persistentContainer.status(for: statusID) {
hashtag = status.hashtags.first { (hashtag) in hashtag = status.hashtags.first { (hashtag) in
hashtag.url == url hashtag.url == url
} }

View File

@ -38,62 +38,62 @@ struct XCBActions {
private static func getStatus(from request: XCBRequest, session: XCBSession, completion: @escaping (Status) -> Void) { private static func getStatus(from request: XCBRequest, session: XCBSession, completion: @escaping (Status) -> Void) {
if let id = request.arguments["statusID"] { if let id = request.arguments["statusID"] {
mastodonController.cache.status(for: id) { (status) in let request = Client.getStatus(id: id)
if let status = status { mastodonController.run(request) { (response) in
completion(status) guard case let .success(status, _) = response else {
} else {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": "Could not get status with ID \(id)" "error": "Could not get status with ID \(id)"
]) ])
return
} }
completion(status)
} }
} else if let searchQuery = request.arguments["statusURL"] { } else if let searchQuery = request.arguments["statusURL"] {
let request = Client.search(query: searchQuery) let request = Client.search(query: searchQuery)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(results, _) = response, if case let .success(results, _) = response,
let status = results.statuses.first { let status = results.statuses.first {
mastodonController.cache.add(status: status)
completion(status) completion(status)
} else { } else {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": "Could not find status by searching '\(searchQuery)'" "error": "Could not find status by searching '\(searchQuery)'"
]) ])
} }
} }
} else { } else {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": "No status provided. Specify either instance-local statusID or remote statusURL." "error": "No status provided. Specify either instance-local statusID or remote statusURL."
]) ])
} }
} }
private static func getAccount(from request: XCBRequest, session: XCBSession, completion: @escaping (Account) -> Void) { private static func getAccount(from request: XCBRequest, session: XCBSession, completion: @escaping (Account) -> Void) {
if let id = request.arguments["accountID"] { if let id = request.arguments["accountID"] {
mastodonController.cache.account(for: id) { (account) in let request = Client.getAccount(id: id)
if let account = account { mastodonController.run(request) { (response) in
completion(account) guard case let .success(account, _) = response else {
} else {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": "Could not get account with ID \(id)" "error": "Could not get account with ID \(id)"
]) ])
return
} }
completion(account)
} }
} else if let searchQuery = request.arguments["accountURL"] { } else if let searchQuery = request.arguments["accountURL"] {
let request = Client.search(query: searchQuery) let request = Client.search(query: searchQuery)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(results, _) = response { if case let .success(results, _) = response {
if let account = results.accounts.first { if let account = results.accounts.first {
mastodonController.cache.add(account: account)
completion(account) completion(account)
} else { } else {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": "Could not find account by searching '\(searchQuery)'" "error": "Could not find account by searching '\(searchQuery)'"
]) ])
} }
} else if case let .failure(error) = response { } else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": error.localizedDescription "error": error.localizedDescription
]) ])
} }
} }
} else if let acct = request.arguments["acct"] { } else if let acct = request.arguments["acct"] {
@ -101,23 +101,22 @@ struct XCBActions {
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(accounts, _) = response { if case let .success(accounts, _) = response {
if let account = accounts.first { if let account = accounts.first {
mastodonController.cache.add(account: account)
completion(account) completion(account)
} else { } else {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": "Could not find account \(acct)" "error": "Could not find account \(acct)"
]) ])
} }
} else if case let .failure(error) = response { } else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": error.localizedDescription "error": error.localizedDescription
]) ])
} }
} }
} else { } else {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": "No status provided. Specify either instance-local ID, account URL, or qualified username." "error": "No status provided. Specify either instance-local ID, account URL, or qualified username."
]) ])
} }
} }
@ -142,7 +141,7 @@ struct XCBActions {
guard CharacterCounter.count(text: status) <= mastodonController.instance.maxStatusCharacters ?? 500 else { guard CharacterCounter.count(text: status) <= mastodonController.instance.maxStatusCharacters ?? 500 else {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": "Too many characters. Instance maximum is \(mastodonController.instance.maxStatusCharacters ?? 500)" "error": "Too many characters. Instance maximum is \(mastodonController.instance.maxStatusCharacters ?? 500)"
]) ])
return return
} }
let request = Client.createStatus(text: status, visibility: Preferences.shared.defaultPostVisibility) let request = Client.createStatus(text: status, visibility: Preferences.shared.defaultPostVisibility)
@ -151,11 +150,11 @@ struct XCBActions {
session.complete(with: .success, additionalData: [ session.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString, "statusURL": status.url?.absoluteString,
"statusURI": status.uri "statusURI": status.uri
]) ])
} else if case let .failure(error) = response { } else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": error.localizedDescription "error": error.localizedDescription
]) ])
} }
} }
} else { } else {
@ -179,7 +178,7 @@ struct XCBActions {
} catch { } catch {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": error.localizedDescription "error": error.localizedDescription
]) ])
return return
} }
} }
@ -192,7 +191,7 @@ struct XCBActions {
"posted": status.createdAt.timeIntervalSince1970.description, "posted": status.createdAt.timeIntervalSince1970.description,
"content": content, "content": content,
"reblog": status.reblog?.id "reblog": status.reblog?.id
]) ])
} }
} }
@ -204,20 +203,19 @@ struct XCBActions {
statusAction(request: Status.reblog, alertTitle: "Reblog status?", request, session, silent) statusAction(request: Status.reblog, alertTitle: "Reblog status?", request, session, silent)
} }
static func statusAction(request: @escaping (Status) -> Request<Status>, alertTitle: String, _ url: XCBRequest, _ session: XCBSession, _ silent: Bool?) { static func statusAction(request: @escaping (String) -> Request<Status>, alertTitle: String, _ url: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
func performAction(status: Status, completion: ((Status) -> Void)?) { func performAction(status: Status, completion: ((Status) -> Void)?) {
mastodonController.run(request(status)) { (response) in mastodonController.run(request(status.id)) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
mastodonController.cache.add(status: status)
completion?(status) completion?(status)
session.complete(with: .success, additionalData: [ session.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString, "statusURL": status.url?.absoluteString,
"statusURI": status.uri "statusURI": status.uri
]) ])
} else if case let .failure(error) = response { } else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": error.localizedDescription "error": error.localizedDescription
]) ])
} }
} }
} }
@ -271,7 +269,7 @@ struct XCBActions {
"url": account.url.absoluteString, "url": account.url.absoluteString,
"avatarURL": account.avatar.absoluteString, "avatarURL": account.avatar.absoluteString,
"headerURL": account.header.absoluteString "headerURL": account.header.absoluteString
]) ])
} }
} }
@ -286,22 +284,21 @@ struct XCBActions {
"url": account.url.absoluteString, "url": account.url.absoluteString,
"avatarURL": account.avatar.absoluteString, "avatarURL": account.avatar.absoluteString,
"headerURL": account.header.absoluteString "headerURL": account.header.absoluteString
]) ])
} }
static func followUser(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) { static func followUser(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
func performAction(_ account: Account) { func performAction(_ account: Account) {
let request = Account.follow(account.id) let request = Account.follow(account.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(relationship, _) = response { if case .success(_, _) = response {
mastodonController.cache.add(relationship: relationship)
session.complete(with: .success, additionalData: [ session.complete(with: .success, additionalData: [
"url": account.url.absoluteString "url": account.url.absoluteString
]) ])
} else if case let .failure(error) = response { } else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [ session.complete(with: .error, additionalData: [
"error": error.localizedDescription "error": error.localizedDescription
]) ])
} }
} }
} }
@ -314,7 +311,8 @@ struct XCBActions {
DispatchQueue.main.async { DispatchQueue.main.async {
show(vc) 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 alertController.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
performAction(account) performAction(account)
})) }))