Compare commits

..

54 Commits

Author SHA1 Message Date
Shadowfacts cf48e4e973
Bump build number 2020-05-13 21:21:57 -04:00
Shadowfacts 2eaeaf3277
Fix previewing gifv attacments 2020-05-13 21:20:22 -04:00
Shadowfacts d396eb0823
Change background CoreData context to be a child of the main context so
that updates on the background context propogate up to the view context
on save
2020-05-13 19:49:35 -04:00
Shadowfacts 35a510e8ed
Add cache reset button to Advanced Preferences 2020-05-13 18:58:11 -04:00
Shadowfacts 0582812563
Remove strong references to MastodonController 2020-05-13 18:57:04 -04:00
Shadowfacts e581f384e4
Fix account descriptions being squashed in the follows list 2020-05-12 22:24:51 -04:00
Shadowfacts c42a48ee12
Fix header images not displaying 2020-05-12 22:05:57 -04:00
Shadowfacts 1c9b1b9ac3
Add support (sort of) for gifv attachments
See #98
2020-05-12 21:46:08 -04:00
Shadowfacts 82ad3b9fc4
Add reference counting for accounts
Closes #97
2020-05-11 22:03:17 -04:00
Shadowfacts 0a89dd3041
Don't double update accounts
Adding a status to the cache will also cache the status' account
2020-05-11 18:27:54 -04:00
Shadowfacts 40863ef130
Fix crash when opening more options for status in instance public timeline 2020-05-11 17:58:43 -04:00
Shadowfacts cd78287a87
Fix crash when viewing instance public timelines
Use a CoreData in-memory store for public timelines.
2020-05-11 17:57:50 -04:00
Shadowfacts 04496aca1d
Apply avatar style to local account avatar images 2020-05-10 19:30:19 -04:00
Shadowfacts 5a098df931
Fix crash when searching 2020-05-10 15:47:50 -04:00
Shadowfacts 9812d4aff2
Prevent double-decrementing reference count for conversation main status 2020-05-10 15:08:45 -04:00
Shadowfacts f4f2a5546c
Prevent race in status action account list 2020-05-10 15:04:22 -04:00
Shadowfacts b220948e2b
Only initialize NSManagedObjectModel once
Prevents CoreData warnings when switching accounts and constructing a
second MastodonCachePersistentStore
2020-05-10 14:54:43 -04:00
Shadowfacts 866edc472d
Show avatar and instance domain in account list in Preferences 2020-05-10 14:54:20 -04:00
Shadowfacts 88e4f52b5d
Fix crash when adding account
Adding a UserData.LocalAccountInfo with a nil username while the
PreferencesView is on screen will cause a crash, since it triggers a
Combine publish upon which the PreferencesView expects to be able to
display the username of all accounts.
2020-05-10 14:41:07 -04:00
Shadowfacts 98529ca5af
Remove notifications from the bottom when scrolling up notifications list 2020-05-10 12:56:03 -04:00
Shadowfacts 6d8c5f632c
Fix scroll-to-top sometimes not scrolling all the way to the top 2020-05-10 12:56:01 -04:00
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 7fb92c9ce3
Prevent avatars in action notification group cell from overflowing 2020-05-06 19:18:47 -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
Shadowfacts fcab6818b0
Hide large image source view during expand/shrink animation 2020-03-25 23:10:48 -04:00
Shadowfacts 80cf1850dd
Add trackpad/magic mouse support for navigation controller interactive push gesture 2020-03-25 22:29:32 -04:00
Shadowfacts e612964464
Allow scrolling w/ trackpad/magic mouse to dismiss gallery 2020-03-25 22:12:26 -04:00
Shadowfacts 49a437583e
Fix incorrect large image size during expand/shrink animation in some
cases
2020-03-25 22:09:00 -04:00
Shadowfacts 8a513186aa
Add pointer interactions status buttons and profile header more button 2020-03-24 23:02:40 -04:00
Shadowfacts d9517047d7
Fix previewing video/audio attachments 2020-03-20 22:48:28 -04:00
Shadowfacts bef3388fe8
Move attachment view corner radius to individual views
Masking the container makes context menu interactions look weird
2020-03-20 22:34:50 -04:00
Shadowfacts 2e8241d734
Move attachment context menu interaction to AttachmentView 2020-03-20 22:28:23 -04:00
Shadowfacts c9c001d403
Improve attachment previewing
- Set correct preview size
- Don't show controls
2020-03-20 22:13:04 -04:00
84 changed files with 2313 additions and 742 deletions

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
import Foundation
public class Mention: Decodable {
public class Mention: Codable {
public let url: URL
public let username: 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
public class Status: Decodable {
public final class Status: /*StatusProtocol,*/ Decodable {
public let id: String
public let uri: String
public let url: URL?
@ -38,22 +38,24 @@ public class Status: Decodable {
public let bookmarked: Bool?
public let card: Card?
public static func getContext(_ status: Status) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
public var applicationName: String? { application?.name }
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> {
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
}
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
request.range = range
return request
}
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
request.range = range
return request
}
@ -62,20 +64,20 @@ public class Status: Decodable {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
}
public static func reblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
public static func reblog(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
}
public static func unreblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
public static func unreblog(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
}
public static func favourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
public static func favourite(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
}
public static func unfavourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
public static func unfavourite(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
}
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")
}
public static func unbookmark(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unbookmark")
public static func unbookmark(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
}
public static func muteConversation(_ status: Status) -> Request<Status> {

View File

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

View File

@ -20,9 +20,14 @@
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.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 */; };
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 */; };
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, ); }; };
@ -105,6 +110,7 @@
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
D63569E023908A8D003DD353 /* StatusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60A4FFB238B726A008AC647 /* StatusState.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 */; };
D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */; };
D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */; };
@ -117,6 +123,7 @@
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; };
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; };
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; };
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; };
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */; };
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18723C1640A000D0238 /* PinStatusActivity.swift */; };
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */; };
@ -125,7 +132,10 @@
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC19123C271D9000D0238 /* MastodonActivity.swift */; };
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.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 */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
@ -149,6 +159,8 @@
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.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 */; };
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; };
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.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 */; };
@ -156,6 +168,7 @@
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */; };
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.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 */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
@ -222,6 +235,8 @@
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; };
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; };
@ -300,10 +315,15 @@
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>"; };
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>"; };
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>"; };
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; };
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>"; };
@ -386,6 +406,7 @@
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>"; };
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>"; };
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>"; };
@ -398,6 +419,7 @@
D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; };
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = "<group>"; };
D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPreviewViewController.swift; sourceTree = "<group>"; };
D64BC18723C1640A000D0238 /* PinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinStatusActivity.swift; sourceTree = "<group>"; };
D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnpinStatusActivity.swift; sourceTree = "<group>"; };
@ -406,7 +428,10 @@
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>"; };
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>"; };
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.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; };
D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -433,6 +458,8 @@
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>"; };
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>"; };
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.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>"; };
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; };
@ -440,6 +467,7 @@
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>"; };
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>"; };
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>"; };
@ -508,6 +536,8 @@
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = "<group>"; };
D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; };
@ -570,10 +600,21 @@
isa = PBXGroup;
children = (
04D14BAE22B34A2800642648 /* GalleryViewController.swift */,
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */,
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */,
);
path = "Attachment Gallery";
sourceTree = "<group>";
};
D60E2F2F24424F0D005F8713 /* Protocols */ = {
isa = PBXGroup;
children = (
D60E2F3024424F1A005F8713 /* StatusProtocol.swift */,
D60E2F3224425374005F8713 /* AccountProtocol.swift */,
);
path = Protocols;
sourceTree = "<group>";
};
D61099AC2144B0CC00432DC2 /* Pachyderm */ = {
isa = PBXGroup;
children = (
@ -633,6 +674,7 @@
D61099DD2144C10C00432DC2 /* Model */ = {
isa = PBXGroup;
children = (
D60E2F2F24424F0D005F8713 /* Protocols */,
D61099DE2144C11400432DC2 /* MastodonError.swift */,
D6109A04214572BF00432DC2 /* Scope.swift */,
D61099E02144C1DC00432DC2 /* Account.swift */,
@ -782,6 +824,17 @@
path = Shortcuts;
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 */ = {
isa = PBXGroup;
children = (
@ -868,6 +921,7 @@
D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */,
D66362702136338600C9CBA2 /* ComposeViewController.swift */,
D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */,
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
);
path = Compose;
sourceTree = "<group>";
@ -895,6 +949,7 @@
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */,
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
0427037B22B316B9000D31B6 /* SilentActionPrefs.swift */,
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
);
path = Preferences;
sourceTree = "<group>";
@ -983,6 +1038,7 @@
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */,
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */,
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */,
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1156,6 +1212,7 @@
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -1164,6 +1221,7 @@
isa = PBXGroup;
children = (
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */,
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */,
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */,
);
path = Attachments;
@ -1205,8 +1263,10 @@
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6028B9A2150811100F223B9 /* MastodonCache.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
D62D241E217AA46B005076CC /* Shortcuts */,
@ -1214,6 +1274,7 @@
D6AEBB3F2321640F00E5038B /* Activities */,
D667E5F62135C2ED0057A976 /* Extensions */,
D61959D2241E846D00A37B8E /* Models */,
D6370B9924421FE00092A7FF /* CoreData */,
D6F953F121251A2F00CF0F2B /* Controllers */,
D641C780213DD7C4004B4513 /* Screens */,
D6BED1722126661300F02DA0 /* Views */,
@ -1549,7 +1610,9 @@
D61099CB2144B20500432DC2 /* Request.swift in Sources */,
D6109A05214572BF00432DC2 /* Scope.swift in Sources */,
D6109A11214607D500432DC2 /* Timeline.swift in Sources */,
D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */,
D61099E7214561FF00432DC2 /* Attachment.swift in Sources */,
D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */,
D61099D02144B2D700432DC2 /* Method.swift in Sources */,
D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */,
D61099FB214569F600432DC2 /* Report.swift in Sources */,
@ -1593,10 +1656,12 @@
buildActionMask = 2147483647;
files = (
D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */,
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */,
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
@ -1623,14 +1688,16 @@
D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */,
D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */,
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
D6028B9B2150811100F223B9 /* MastodonCache.swift in Sources */,
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
@ -1642,6 +1709,8 @@
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */,
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
@ -1685,7 +1754,9 @@
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */,
D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */,
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */,
@ -1698,19 +1769,24 @@
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */,
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */,
D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */,
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */,
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */,
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
@ -2018,7 +2094,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
@ -2043,7 +2119,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
@ -2219,6 +2295,19 @@
productName = SheetController;
};
/* 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 */;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@ class UnpinStatusActivity: StatusActivity {
let request = Status.unpin(status)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.cache.add(status: status)
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
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,20 +30,26 @@ class MastodonController {
}
}
private(set) lazy var cache = MastodonCache(mastodonController: self)
static func resetAll() {
all = [:]
}
private let transient: Bool
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL
private(set) var accountInfo: LocalData.UserAccountInfo?
var accountInfo: LocalData.UserAccountInfo?
let client: Client!
var account: Account!
var instance: Instance!
init(instanceURL: URL) {
init(instanceURL: URL, transient: Bool = false) {
self.instanceURL = instanceURL
self.accountInfo = nil
self.client = Client(baseURL: instanceURL)
self.transient = transient
}
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
@ -82,8 +88,16 @@ class MastodonController {
run(request) { response in
guard case let .success(account, _) = response else { fatalError() }
self.account = account
self.cache.add(account: account)
completion?(account)
self.persistentContainer.backgroundContext.perform {
if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self.persistentContainer)
} else {
// the first time the user's account is added to the store,
// increment its reference count so that it's never removed
self.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true)
}
completion?(account)
}
}
}
}

View File

@ -0,0 +1,105 @@
//
// 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 referenceCount: Int
@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 }
func incrementReferenceCount() {
referenceCount += 1
}
func decrementReferenceCount() {
referenceCount -= 1
if referenceCount <= 0 {
managedObjectContext!.delete(self)
}
}
public override func prepareForDeletion() {
super.prepareForDeletion()
movedTo?.decrementReferenceCount()
}
}
extension AccountMO {
convenience init(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiAccount: account, container: container)
movedTo?.incrementReferenceCount()
}
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,190 @@
//
// 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 static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext
return context
}()
let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>()
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
if transient {
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
let storeDescription = NSPersistentStoreDescription()
storeDescription.type = NSInMemoryStoreType
persistentStoreDescriptions = [storeDescription]
} else {
super.init(name: "\(accountInfo!.id)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
}
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()
}
statuses.forEach { self.statusSubject.send($0.id) }
completion?()
}
}
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, incrementReferenceCount: Bool) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self)
if incrementReferenceCount {
accountMO.incrementReferenceCount()
}
return accountMO
} else {
let accountMO = AccountMO(apiAccount: account, container: self, context: self.backgroundContext)
if incrementReferenceCount {
accountMO.incrementReferenceCount()
}
return accountMO
}
}
func addOrUpdate(account: Account, incrementReferenceCount: Bool, completion: ((AccountMO) -> Void)? = nil) {
backgroundContext.perform {
let accountMO = self.upsert(account: account, incrementReferenceCount: incrementReferenceCount)
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, incrementReferenceCount: true) }
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 }
// filter out mentions, otherwise we would double increment the reference count of those accounts
// since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
accounts.forEach { self.accountSubject.send($0.id) }
}
}
func performBatchUpdates(_ block: @escaping (_ context: NSManagedObjectContext, _ addAccounts: ([Account]) -> Void, _ addStatuses: ([Status]) -> Void) -> Void, completion: (() -> Void)? = nil) {
backgroundContext.perform {
var updatedAccounts = [String]()
var updatedStatuses = [String]()
block(self.backgroundContext, { (accounts) in
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
updatedStatuses.append(contentsOf: statuses.map { $0.id })
})
updatedAccounts.forEach(self.accountSubject.send)
updatedStatuses.forEach(self.statusSubject.send)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
}
}
}

View File

@ -0,0 +1,134 @@
//
// 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()
account.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()
account.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 ?? false
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 ?? false
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="referenceCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<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="328"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="418"/>
</elements>
</model>

View File

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

View File

@ -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 clientId = info["clientID"],
let secret = info["clientSecret"],
let username = info["username"],
let accessToken = info["accessToken"] else {
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 {
return []
@ -56,15 +55,18 @@ class LocalData: ObservableObject {
}
set {
objectWillChange.send()
let array = newValue.map { (info) in
return [
let array = newValue.map { (info) -> [String: String] in
var res = [
"id": info.id,
"instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID,
"clientSecret": info.clientSecret,
"username": info.username,
"accessToken": info.accessToken
]
if let username = info.username {
res["username"] = username
}
return res
}
defaults.set(array, forKey: accountsKey)
}
@ -85,7 +87,7 @@ class LocalData: ObservableObject {
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
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index)
@ -97,6 +99,13 @@ class LocalData: ObservableObject {
return info
}
func setUsername(for info: UserAccountInfo, username: String) {
var info = info
info.username = username
removeAccount(info)
accounts.append(info)
}
func removeAccount(_ info: UserAccountInfo) {
accounts.removeAll(where: { $0.id == info.id })
}
@ -128,7 +137,7 @@ extension LocalData {
let instanceURL: URL
let clientID: String
let clientSecret: String
let username: String
fileprivate(set) var username: String!
let accessToken: String
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 Photos
import MobileCoreServices
import PencilKit
enum CompositionAttachmentData {
case asset(PHAsset)
case image(UIImage)
case video(URL)
case drawing(PKDrawing)
var type: AttachmentType {
switch self {
@ -23,6 +25,8 @@ enum CompositionAttachmentData {
return .image
case .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 {
case let .image(image):
completion(image.pngData()!, "image/png")
@ -90,6 +94,10 @@ enum CompositionAttachmentData {
fatalError("failed to create export session")
}
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)
case .video(_):
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")
}
self = .image(image)
case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing)
default:
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
/// The local identifier of the PHAsset for this attachment
case assetIdentifier
/// The PKDrawing object for this attachment.
case drawing
}
}
@ -178,6 +196,8 @@ extension CompositionAttachmentData: Equatable {
return a == b
case let (.video(a), .video(b)):
return a == b
case let (.drawing(a), .drawing(b)):
return a == b
default:
return false
}

View File

@ -109,6 +109,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// 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
// to restore the scene back to its current state.
try! scene.session.mastodonController?.persistentContainer.viewContext.save()
}
func activateAccount(_ account: LocalData.UserAccountInfo) {

View File

@ -32,7 +32,8 @@ class AccountListTableViewController: EnhancedTableViewController {
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.rowHeight = 66
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 66
tableView.alwaysBounceVertical = true

View File

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

View File

@ -0,0 +1,46 @@
//
// AttachmentPreviewViewController.swift
// Tusker
//
// Created by Shadowfacts on 3/20/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Gifu
class AttachmentPreviewViewController: UIViewController {
let attachment: Attachment
init(attachment: Attachment) {
self.attachment = attachment
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
if let data = ImageCache.attachments.get(attachment.url),
let image = UIImage(data: data) {
let imageView: UIImageView
if attachment.url.pathExtension == "gif" {
let gifView = GIFImageView(image: image)
gifView.animate(withGIFData: data)
imageView = gifView
} else {
imageView = UIImageView(image: image)
}
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
view = imageView
preferredContentSize = image.size
} else {
view = UIActivityIndicatorView(style: .large)
}
}
}

View File

@ -13,7 +13,7 @@ import AVKit
class GalleryViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, LargeImageAnimatableViewController {
let attachments: [Attachment]
let sourcesInfo: [LargeImageViewController.SourceInfo?]
let sourceViews: WeakArray<UIImageView>
let startIndex: Int
let pages: [UIViewController]
@ -26,12 +26,13 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
return index
}
var animationSourceInfo: LargeImageViewController.SourceInfo? { sourcesInfo[currentIndex] }
var animationSourceView: UIImageView? { sourceViews[currentIndex] }
var animationImage: UIImage? {
if let sourceImage = sourcesInfo[currentIndex]?.image {
return sourceImage
if let page = pages[currentIndex] as? LoadingLargeImageViewController,
let image = page.largeImageVC?.image {
return image
} else {
return (pages[currentIndex] as? LoadingLargeImageViewController)?.largeImageVC?.image
return animationSourceView?.image
}
}
var animationGifData: Data? {
@ -59,9 +60,9 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
}
}
init(attachments: [Attachment], sourcesInfo: [LargeImageViewController.SourceInfo?], startIndex: Int) {
init(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) {
self.attachments = attachments
self.sourcesInfo = sourcesInfo
self.sourceViews = WeakArray(sourceViews)
self.startIndex = startIndex
self.pages = attachments.map {
@ -74,6 +75,8 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
let vc = AVPlayerViewController()
vc.player = AVPlayer(url: $0.url)
return vc
case .gifv:
return GifvAttachmentViewController(attachment: $0)
default:
fatalError()
}

View File

@ -0,0 +1,33 @@
//
// GifvAttachmentViewController.swift
// Tusker
//
// Created by Shadowfacts on 5/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import AVFoundation
class GifvAttachmentViewController: UIViewController {
private let attachment: Attachment
init(attachment: Attachment) {
precondition(attachment.kind == .gifv)
self.attachment = attachment
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let asset = AVURLAsset(url: attachment.url)
self.view = GifvAttachmentView(asset: asset, gravity: .resizeAspect)
}
}

View File

@ -45,7 +45,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
let request = Client.getBookmarks()
mastodonController.run(request) { (response) in
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.newer = pagination?.newer
self.older = pagination?.older
@ -87,7 +87,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
mastodonController.run(request) { (response) in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older
self.mastodonController.cache.addAll(statuses: newStatuses)
self.mastodonController.persistentContainer.addAll(statuses: newStatuses)
let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map {
IndexPath(row: $0, section: 0)
}
@ -112,15 +112,15 @@ class BookmarksTableViewController: EnhancedTableViewController {
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
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
}
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
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)
}
}
@ -138,13 +138,13 @@ class BookmarksTableViewController: EnhancedTableViewController {
}
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 [
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
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)
}
})
@ -165,7 +165,7 @@ extension BookmarksTableViewController: StatusTableViewCellDelegate {
extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
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)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
@ -175,7 +175,7 @@ extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
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)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)

View File

@ -9,6 +9,7 @@
import UIKit
import Pachyderm
import MobileCoreServices
import PencilKit
protocol ComposeAttachmentsViewControllerDelegate: class {
func composeSelectedAttachmentsDidChange()
@ -38,6 +39,8 @@ class ComposeAttachmentsViewController: UITableViewController {
}
}
private var currentlyEditedDrawingIndex: Int?
init(attachments: [CompositionAttachment], mastodonController: MastodonController) {
self.attachments = attachments
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) {
let group = DispatchGroup()
@ -270,19 +290,49 @@ class ComposeAttachmentsViewController: UITableViewController {
}
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]
// 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
switch attachment.data {
case .drawing(_):
actions.append(UIAction(title: "Edit Drawing", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.presentComposeDrawingViewController(editingAttachmentAt: indexPath.row)
}))
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
}
}
private func targetedPreview(forConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?,
indexPath.section == 0,
let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell {
let parameters = UIPreviewParameters()
parameters.backgroundColor = .black
@ -452,3 +502,26 @@ extension ComposeAttachmentsViewController: ComposeAttachmentTableViewCellDelega
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.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 }
} else {
accountsToMention = []
@ -164,17 +164,20 @@ class ComposeViewController: UIViewController {
}
if let inReplyToID = inReplyToID {
if let status = mastodonController.cache.status(for: inReplyToID) {
if let status = mastodonController.persistentContainer.status(for: inReplyToID) {
updateInReplyTo(inReplyTo: status)
} else {
let loadingVC = LoadingViewController()
embedChild(loadingVC)
mastodonController.cache.status(for: inReplyToID) { (status) in
guard let status = status else { return }
DispatchQueue.main.async {
self.updateInReplyTo(inReplyTo: status)
loadingVC.removeViewAndController()
let request = Client.getStatus(id: inReplyToID)
mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else { return }
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true) { (status) in
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
if Preferences.shared.contentWarningCopyMode == .doNotCopy {
contentWarningEnabled = false
@ -213,7 +216,8 @@ class ComposeViewController: UIViewController {
replyAvatarImageViewTopConstraint!.isActive = true
inReplyToContainer.isHidden = false
inReplyToLabel.text = "In reply to \(inReplyTo.account.displayOrUserName)"
// todo: update to use managed objects
inReplyToLabel.text = "In reply to \(inReplyTo.account.displayName)"
}
override func viewWillAppear(_ animated: Bool) {
@ -233,7 +237,7 @@ class ComposeViewController: UIViewController {
return []
}
return StatusFormat.allCases.map { (format) in
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in
let item: UIBarButtonItem
if let image = format.image {
item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
@ -248,6 +252,14 @@ class ComposeViewController: UIViewController {
item.accessibilityLabel = format.accessibilityLabel
return item
}
for i in (1..<StatusFormat.allCases.count).reversed() {
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = 8
formatButtons.insert(spacer, at: i)
}
return formatButtons
}
@objc func adjustForKeyboard(notification: NSNotification) {
@ -461,7 +473,7 @@ class ComposeViewController: UIViewController {
self.mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else { fatalError() }
self.postedStatus = status
self.mastodonController.cache.add(status: status)
// self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true)
if let draft = self.currentDraft {
DraftsManager.shared.remove(draft)
@ -472,8 +484,8 @@ class ComposeViewController: UIViewController {
self.dismiss(animated: true)
// todo: this doesn't work
let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController)
self.show(conversationVC, sender: self)
// let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController)
// self.show(conversationVC, sender: self)
self.xcbSession?.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,

View File

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

View File

@ -12,7 +12,7 @@ import Pachyderm
class ExploreViewController: EnhancedTableViewController {
let mastodonController: MastodonController
weak var mastodonController: MastodonController!
var dataSource: DataSource!

View File

@ -11,10 +11,8 @@ import Gifu
class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeImageAnimatableViewController {
typealias SourceInfo = (image: UIImage?, frame: CGRect, cornerRadius: CGFloat)
var animationSourceInfo: SourceInfo?
var animationImage: UIImage? { animationSourceInfo?.image ?? image }
weak var animationSourceView: UIImageView?
var animationImage: UIImage? { image ?? animationSourceView?.image }
var animationGifData: Data? { gifData }
var dismissInteractionController: LargeImageInteractionController?
@ -59,10 +57,10 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
return !controlsVisible
}
init(image: UIImage, description: String?, sourceInfo: SourceInfo?) {
init(image: UIImage, description: String?, sourceView: UIImageView?) {
self.image = image
self.imageDescription = description
self.animationSourceInfo = sourceInfo
self.animationSourceView = sourceView
super.init(nibName: "LargeImageViewController", bundle: nil)
@ -182,6 +180,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
return zoomRect
}
// MARK: Interaction
func animateZoomOut() {
UIView.animate(withDuration: 0.3, animations: {
self.scrollView.zoomScale = self.scrollView.minimumZoomScale

View File

@ -35,8 +35,8 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
var shrinkGestureEnabled = true
var animationSourceInfo: LargeImageViewController.SourceInfo?
var animationImage: UIImage? { animationSourceInfo?.image ?? largeImageVC?.image }
weak var animationSourceView: UIImageView?
var animationImage: UIImage? { largeImageVC?.image ?? animationSourceView?.image }
var animationGifData: Data? { largeImageVC?.gifData }
var dismissInteractionController: LargeImageInteractionController?
@ -108,7 +108,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
func createLargeImage(data: Data) {
guard let image = UIImage(data: data) else { return }
largeImageVC = LargeImageViewController(image: image, description: imageDescription, sourceInfo: nil)
largeImageVC = LargeImageViewController(image: image, description: imageDescription, sourceView: animationSourceView)
largeImageVC!.initialControlsVisible = initialControlsVisible
largeImageVC!.shrinkGestureEnabled = false
if url.pathExtension == "gif" {

View File

@ -10,12 +10,30 @@ import UIKit
import Gifu
protocol LargeImageAnimatableViewController: UIViewController {
var animationSourceInfo: LargeImageViewController.SourceInfo? { get }
var animationSourceView: UIImageView? { get }
var animationImage: UIImage? { get }
var animationGifData: Data? { get }
var dismissInteractionController: LargeImageInteractionController? { get }
}
extension LargeImageAnimatableViewController {
func sourceViewFrame(in coordinateSpace: UIView) -> CGRect? {
guard let sourceView = animationSourceView else { return nil }
var sourceFrame = sourceView.convert(sourceView.bounds, to: coordinateSpace)
if let scrollView = coordinateSpace as? UIScrollView {
let scale = scrollView.zoomScale
let width = sourceFrame.width * scale
let height = sourceFrame.height * scale
let x = sourceFrame.minX * scale - scrollView.contentOffset.x + scrollView.frame.minX
let y = sourceFrame.minY * scale - scrollView.contentOffset.y + scrollView.frame.minY
sourceFrame = CGRect(x: x, y: y, width: width, height: height)
}
return sourceFrame
}
}
class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
@ -32,25 +50,35 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
containerView.addSubview(toVC.view)
let finalVCFrame = transitionContext.finalFrame(for: toVC)
guard let sourceInfo = toVC.animationSourceInfo,
guard let sourceView = toVC.animationSourceView,
let sourceFrame = toVC.sourceViewFrame(in: fromVC.view),
let image = toVC.animationImage else {
toVC.view.frame = finalVCFrame
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return
}
let ratio = image.size.width / image.size.height
let width = finalVCFrame.width
let height = width / ratio
let finalFrame = CGRect(x: finalVCFrame.midX - width / 2, y: finalVCFrame.midY - height / 2, width: width, height: height)
// use alpha, becaus isHidden makes stack views re-layout
sourceView.alpha = 0
let imageView = GIFImageView(frame: sourceInfo.frame)
var finalFrameSize = finalVCFrame.inset(by: fromVC.view.safeAreaInsets).size
let newWidth = finalFrameSize.width / image.size.width
let newHeight = finalFrameSize.height / image.size.height
if newHeight < newWidth {
finalFrameSize.width = newHeight * image.size.width
} else {
finalFrameSize.height = newWidth * image.size.height
}
let finalFrame = CGRect(origin: CGPoint(x: finalVCFrame.midX - finalFrameSize.width / 2, y: finalVCFrame.midY - finalFrameSize.height / 2), size: finalFrameSize)
let imageView = GIFImageView(frame: sourceFrame)
imageView.image = image
if let gifData = toVC.animationGifData {
imageView.animate(withGIFData: gifData)
}
imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = sourceInfo.cornerRadius
imageView.layer.cornerRadius = sourceView.layer.cornerRadius
imageView.layer.maskedCorners = sourceView.layer.maskedCorners
imageView.layer.masksToBounds = true
let blackView = UIView(frame: finalVCFrame)
@ -79,6 +107,9 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
fromVC.view.isHidden = false
blackView.removeFromSuperview()
imageView.removeFromSuperview()
sourceView.alpha = 1
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}

View File

@ -19,7 +19,11 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
init(viewController: UIViewController) {
super.init()
self.viewController = viewController
viewController.view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:))))
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:)))
if #available(iOS 13.4, *) {
panRecognizer.allowedScrollTypesMask = .all
}
viewController.view.addGestureRecognizer(panRecognizer)
}
@objc func handleGesture(_ recognizer: UIPanGestureRecognizer) {

View File

@ -27,19 +27,28 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
return
}
guard let sourceInfo = fromVC.animationSourceInfo,
guard let sourceView = fromVC.animationSourceView,
let sourceFrame = fromVC.sourceViewFrame(in: toVC.view),
let image = fromVC.animationImage else {
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return
}
let originalVCFrame = fromVC.view.frame
// use alpha, becaus isHidden makes stack views re-layout
sourceView.alpha = 0
let containerView = transitionContext.containerView
let ratio = image.size.width / image.size.height
let width = originalVCFrame.width
let height = width / ratio
let originalFrame = CGRect(x: originalVCFrame.midX - width / 2, y: originalVCFrame.midY - height / 2, width: width, height: height)
let originalVCFrame = fromVC.view.frame
var originalFrameSize = originalVCFrame.inset(by: fromVC.view.safeAreaInsets).size
let newWidth = originalFrameSize.width / image.size.width
let newHeight = originalFrameSize.height / image.size.height
if newHeight < newWidth {
originalFrameSize.width = newHeight * image.size.width
} else {
originalFrameSize.height = newWidth * image.size.height
}
let originalFrame = CGRect(origin: CGPoint(x: originalVCFrame.midX - originalFrameSize.width / 2, y: originalVCFrame.midY - originalFrameSize.height / 2), size: originalFrameSize)
let imageView = GIFImageView(frame: originalFrame)
imageView.image = image
@ -60,8 +69,9 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, animations: {
imageView.frame = sourceInfo.frame
imageView.layer.cornerRadius = sourceInfo.cornerRadius
imageView.frame = sourceFrame
imageView.layer.cornerRadius = sourceView.layer.cornerRadius
imageView.layer.maskedCorners = sourceView.layer.maskedCorners
blackView.alpha = 0
}, completion: { _ in
blackView.removeFromSuperview()
@ -69,6 +79,9 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
if transitionContext.transitionWasCancelled {
toVC.view.removeFromSuperview()
}
sourceView.alpha = 1
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}

View File

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

View File

@ -17,7 +17,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
private let followRequestCell = "followRequestCell"
private let unknownCell = "unknownCell"
let mastodonController: MastodonController
weak var mastodonController: MastodonController!
let excludedTypes: [Pachyderm.Notification.Kind]
let groupTypes = [Notification.Kind.favourite, .reblog, .follow]
@ -63,15 +63,13 @@ class NotificationsTableViewController: EnhancedTableViewController {
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.older = pagination?.older
DispatchQueue.main.async {
self.tableView.reloadData()
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
}
@ -92,7 +90,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
switch group.kind {
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 {
fatalError()
}
@ -113,7 +111,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
return cell
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() }
cell.delegate = self
cell.updateUI(notification: notification)
@ -129,6 +127,33 @@ class NotificationsTableViewController: EnhancedTableViewController {
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// see TimelineTableViewController.tableView(_:willDisplay:forRowAt:)
if !isCurrentlyScrollingToTop, scrollViewDirection < 0 {
let pageSize = 20
if groups.count > 2 * pageSize,
indexPath.row < groups.count - (2 * pageSize) {
let groupsToRemove = groups[groups.count - pageSize..<groups.count]
for group in groupsToRemove {
for notification in group.notifications {
// todo: reference count accounts
// mastodonController.persistentContainer.account(for: notification.account.id)?.decrementReferenceCount()
if let id = notification.status?.id {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
}
let removedIndexPaths = (groups.count - 20..<groups.count).map { IndexPath(row: $0, section: 0) }
groups.removeLast(pageSize)
DispatchQueue.main.async {
UIView.performWithoutAnimation {
tableView.deleteRows(at: removedIndexPaths, with: .none)
}
}
}
}
if indexPath.row == groups.count - 1 {
guard let older = older else { return }
@ -143,15 +168,13 @@ class NotificationsTableViewController: EnhancedTableViewController {
}
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
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
}
}
}
@ -195,8 +218,8 @@ class NotificationsTableViewController: EnhancedTableViewController {
func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
let group = DispatchGroup()
groups[indexPath.row].notificationIDs
.map(Pachyderm.Notification.dismiss(id:))
groups[indexPath.row].notifications
.map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in
group.enter()
mastodonController.run(request) { (response) in
@ -221,26 +244,24 @@ class NotificationsTableViewController: EnhancedTableViewController {
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 {
self.newer = newer
}
DispatchQueue.main.async {
let newIndexPaths = (0..<groups.count).map {
IndexPath(row: $0, section: 0)
}
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
DispatchQueue.main.async {
let newIndexPaths = (0..<groups.count).map {
IndexPath(row: $0, section: 0)
}
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)
self.tableView.scrollToRow(at: IndexPath(row: newNotifications.count, section: 0), at: .top, animated: false)
// 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)
}
}
}
}
@ -259,8 +280,8 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
for notificationID in groups[indexPath.row].notificationIDs {
guard let notification = mastodonController.cache.notification(for: notificationID) else { continue }
for notification in groups[indexPath.row].notifications {
// todo: this account object could be stale
_ = ImageCache.avatars.get(notification.account.avatar, completion: nil)
}
}
@ -268,8 +289,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
for notificationID in groups[indexPath.row].notificationIDs {
guard let notification = mastodonController.cache.notification(for: notificationID) else { continue }
for notification in groups[indexPath.row].notifications {
ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
}
}

View File

@ -68,9 +68,15 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
let authCode = item.value else { return }
mastodonController.authorize(authorizationCode: authCode) { (accessToken) in
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch it's own account
let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo
mastodonController.getOwnAccount { (account) in
DispatchQueue.main.async {
// this needs to happen on the main thread because it publishes a new value for the ObservableObject
let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: account.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
}

View File

@ -15,6 +15,7 @@ struct AdvancedPrefsView : View {
List {
formattingSection
automationSection
cachingSection
}.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Advanced"))
}
@ -41,6 +42,27 @@ struct AdvancedPrefsView : View {
}
}
}
var cachingSection: some View {
Section(header: Text("CACHING")) {
Button(action: clearCache) {
Text("Clear Cache")
}.foregroundColor(.red)
}
}
func clearCache() {
for account in LocalData.shared.accounts {
let controller = MastodonController.getForAccount(account)
let coordinator = controller.persistentContainer.persistentStoreCoordinator
for store in coordinator.persistentStores {
try! coordinator.destroyPersistentStore(at: store.url!, ofType: store.type, options: store.options)
}
}
MastodonController.resetAll()
let mostRecent = LocalData.shared.getMostRecentAccount()!
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": mostRecent])
}
}
extension StatusContentType {

View File

@ -0,0 +1,55 @@
//
// LocalAccountAvatarView.swift
// Tusker
//
// Created by Shadowfacts on 5/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct LocalAccountAvatarView: View {
let localAccountInfo: LocalData.UserAccountInfo
@State var avatarImage: UIImage? = nil
@ObservedObject var preferences = Preferences.shared
var body: some View {
let image: Image
if avatarImage == nil {
let imageName: String
switch preferences.avatarStyle {
case .circle:
imageName = "person.crop.circle"
case .roundRect:
imageName = "person.crop.square"
}
image = Image(systemName: imageName).resizable()
} else {
image = Image(uiImage: self.avatarImage!).renderingMode(.original)
}
return image
.resizable()
.frame(width: 30, height: 30)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30)
.onAppear(perform: self.loadImage)
}
func loadImage() {
let controller = MastodonController.getForAccount(localAccountInfo)
controller.getOwnAccount { (account) in
_ = ImageCache.avatars.get(account.avatar) { (data) in
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.avatarImage = image
}
}
}
}
}
}
//struct LocalAccountAvatarView_Previews: PreviewProvider {
// static var previews: some View {
// LocalAccountAvatarView()
// }
//}

View File

@ -21,8 +21,14 @@ struct PreferencesView: View {
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account])
}) {
HStack {
Text(account.username)
.foregroundColor(.primary)
LocalAccountAvatarView(localAccountInfo: account)
VStack(alignment: .leading) {
Text(verbatim: account.username)
.foregroundColor(.primary)
Text(verbatim: account.instanceURL.host!)
.font(.caption)
.foregroundColor(.primary)
}
Spacer()
if account == self.localData.getMostRecentAccount() {
Image(systemName: "checkmark")

View File

@ -61,6 +61,14 @@ class ProfileTableViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemeneted")
}
deinit {
if let id = accountID, let container = mastodonController?.persistentContainer {
container.backgroundContext.perform {
container.account(for: id, in: container.backgroundContext)?.decrementReferenceCount()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
@ -73,13 +81,14 @@ class ProfileTableViewController: EnhancedTableViewController {
tableView.prefetchDataSource = self
if let accountID = accountID {
if mastodonController.cache.account(for: accountID) != nil {
if mastodonController.persistentContainer.account(for: accountID) != nil {
updateAccountUI()
} else {
loadingVC = LoadingViewController()
embedChild(loadingVC!)
mastodonController.cache.account(for: accountID) { (account) in
guard account != nil else {
let request = Client.getAccount(id: accountID)
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)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { (_) in
self.navigationController!.popViewController(animated: true)
@ -89,9 +98,11 @@ class ProfileTableViewController: EnhancedTableViewController {
}
return
}
DispatchQueue.main.async {
self.updateAccountUI()
self.tableView.reloadData()
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (_) in
DispatchQueue.main.async {
self.updateAccountUI()
self.tableView.reloadData()
}
}
}
}
@ -112,23 +123,25 @@ class ProfileTableViewController: EnhancedTableViewController {
getStatuses(onlyPinned: true) { (response) in
guard case let .success(statuses, _) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: statuses)
self.pinnedStatuses = statuses.map { ($0.id, .unknown) }
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.pinnedStatuses = statuses.map { ($0.id, .unknown) }
}
}
getStatuses() { response in
guard case let .success(statuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: statuses)
self.timelineSegments.append(statuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.timelineSegments.append(statuses.map { ($0.id, .unknown) })
self.older = pagination?.older
self.newer = pagination?.newer
self.older = pagination?.older
self.newer = pagination?.newer
}
}
}
@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
}
@ -138,7 +151,7 @@ class ProfileTableViewController: EnhancedTableViewController {
}
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))
present(vc, animated: true)
}
@ -152,7 +165,7 @@ class ProfileTableViewController: EnhancedTableViewController {
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
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 {
return pinnedStatuses.count
} else {
@ -187,16 +200,20 @@ class ProfileTableViewController: EnhancedTableViewController {
// MARK: - Table view delegate
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 {
guard let older = older else { return }
getStatuses(for: older) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: newStatuses)
self.timelineSegments[indexPath.section - 2].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
self.timelineSegments[indexPath.section - 2].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
self.older = pagination?.older
self.older = pagination?.older
}
}
}
}
@ -219,34 +236,35 @@ class ProfileTableViewController: EnhancedTableViewController {
getStatuses(for: newer) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: newStatuses)
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
if let newer = pagination?.newer {
self.newer = newer
}
if let newer = pagination?.newer {
self.newer = newer
}
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
}
}
}
getStatuses(onlyPinned: true) { (response) in
guard case let .success(newPinnedStatuses, _) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: newPinnedStatuses)
let oldPinnedStatuses = self.pinnedStatuses
var pinnedStatuses = [(id: String, state: StatusState)]()
for status in newPinnedStatuses {
let state: StatusState
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
state = oldState
} else {
state = .unknown
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatuses) {
let oldPinnedStatuses = self.pinnedStatuses
var pinnedStatuses = [(id: String, state: StatusState)]()
for status in newPinnedStatuses {
let state: StatusState
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
state = oldState
} else {
state = .unknown
}
pinnedStatuses.append((status.id, state))
}
pinnedStatuses.append((status.id, state))
self.pinnedStatuses = pinnedStatuses
}
self.pinnedStatuses = pinnedStatuses
}
}
@ -268,13 +286,12 @@ extension ProfileTableViewController: StatusTableViewCellDelegate {
extension ProfileTableViewController: ProfileHeaderTableViewCellDelegate {
func showMoreOptions(cell: ProfileHeaderTableViewCell) {
let account = mastodonController.cache.account(for: accountID)!
mastodonController.cache.relationship(for: account.id) { [weak self] (relationship) in
guard let self = self else { return }
let account = mastodonController.persistentContainer.account(for: accountID)!
let request = Client.getRelationships(accounts: [account.id])
mastodonController.run(request) { (response) in
var customActivities: [UIActivity] = [OpenInSafariActivity()]
if let relationship = relationship {
if case let .success(results, _) = response, let relationship = results.first {
let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity()
customActivities.insert(toggleFollowActivity, at: 0)
}
@ -293,7 +310,7 @@ extension ProfileTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths where indexPath.section > 1 {
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)
for attachment in status.attachments {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
@ -304,7 +321,7 @@ extension ProfileTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths where indexPath.section > 1 {
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)
for attachment in status.attachments {
ImageCache.attachments.cancelWithoutCallback(attachment.url)

View File

@ -116,10 +116,8 @@ class SearchResultsViewController: EnhancedTableViewController {
}
self.currentQuery = query
if self.dataSource.snapshot().numberOfItems == 0 {
activityIndicator.isHidden = false
activityIndicator.startAnimating()
}
activityIndicator.isHidden = false
activityIndicator.startAnimating()
let request = Client.search(query: query, resolve: true, limit: 10)
mastodonController.run(request) { (response) in
@ -132,23 +130,39 @@ class SearchResultsViewController: EnhancedTableViewController {
guard self.currentQuery == query else { return }
let oldSnapshot = self.dataSource.snapshot()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
if self.onlySections.contains(.accounts) && !results.accounts.isEmpty {
snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
self.mastodonController.cache.addAll(accounts: results.accounts)
}
if self.onlySections.contains(.hashtags) && !results.hashtags.isEmpty {
snapshot.appendSections([.hashtags])
snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
}
if self.onlySections.contains(.statuses) && !results.statuses.isEmpty {
snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
self.mastodonController.cache.addAll(statuses: results.statuses)
self.mastodonController.cache.addAll(accounts: results.statuses.map { $0.account })
}
self.dataSource.apply(snapshot)
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
oldSnapshot.itemIdentifiers(inSection: .accounts).forEach { (item) in
guard case let .account(id) = item else { return }
self.mastodonController.persistentContainer.account(for: id, in: context)?.decrementReferenceCount()
}
oldSnapshot.itemIdentifiers(inSection: .statuses).forEach { (item) in
guard case let .status(id, _) = item else { return }
self.mastodonController.persistentContainer.status(for: id, in: context)?.decrementReferenceCount()
}
if self.onlySections.contains(.accounts) && !results.accounts.isEmpty {
snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
addAccounts(results.accounts)
}
if self.onlySections.contains(.hashtags) && !results.hashtags.isEmpty {
snapshot.appendSections([.hashtags])
snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
}
if self.onlySections.contains(.statuses) && !results.statuses.isEmpty {
snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
addStatuses(results.statuses)
}
}, completion: {
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
})
}
}

View File

@ -14,7 +14,7 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
private let statusCell = "statusCell"
private let accountCell = "accountCell"
let mastodonController: MastodonController
weak var mastodonController: MastodonController!
let actionType: ActionType
let statusID: String
@ -58,6 +58,16 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let accountIDs = self.accountIDs, let container = mastodonController?.persistentContainer {
container.backgroundContext.perform {
for id in accountIDs {
container.account(for: id, in: container.backgroundContext)?.decrementReferenceCount()
}
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
@ -71,21 +81,26 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude))
if accountIDs == nil {
if let accountIDs = accountIDs {
accountIDs.forEach { (id) in
self.mastodonController.persistentContainer.account(for: id)?.incrementReferenceCount()
}
} else {
// 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)")
}
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
guard case let .success(accounts, _) = response else { fatalError() }
self.mastodonController.cache.addAll(accounts: accounts)
DispatchQueue.main.async {
self.accountIDs = accounts.map { $0.id }
self.tableView.tableFooterView = nil
self.mastodonController.persistentContainer.addAll(accounts: accounts) {
DispatchQueue.main.async {
self.accountIDs = accounts.map { $0.id }
self.tableView.tableFooterView = nil
}
}
}
}

View File

@ -37,7 +37,7 @@ class InstanceTimelineViewController: TimelineTableViewController {
self.instanceURL = url
// the timeline VC only stores a weak reference to it, so we need to store a strong reference to make sure it's not released immediately
instanceMastodonController = MastodonController(instanceURL: url)
instanceMastodonController = MastodonController(instanceURL: url, transient: true)
super.init(for: .public(local: true), mastodonController: instanceMastodonController)

View File

@ -38,6 +38,18 @@ class TimelineTableViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemented")
}
deinit {
guard let persistentContainer = mastodonController?.persistentContainer else { return }
// 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 {
persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
}
func statusID(for indexPath: IndexPath) -> String {
return timelineSegments[indexPath.section][indexPath.row].id
}
@ -59,12 +71,14 @@ class TimelineTableViewController: EnhancedTableViewController {
let request = Client.getStatuses(timeline: timeline)
mastodonController.run(request) { response in
guard case let .success(statuses, pagination) = response else { fatalError() }
self.mastodonController.cache.addAll(statuses: statuses)
// todo: possible race condition here? we update the underlying data before waiting to reload the table view
self.timelineSegments.insert(statuses.map { ($0.id, .unknown) }, at: 0)
self.newer = pagination?.newer
self.older = pagination?.older
DispatchQueue.main.async {
self.tableView.reloadData()
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
}
@ -93,6 +107,60 @@ class TimelineTableViewController: EnhancedTableViewController {
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// don't remove rows when jumping to the top, otherwise jumping back down might try to show removed rows
// when scrolling upwards, decrement reference counts for old statuses, if necessary
if !isCurrentlyScrollingToTop, 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(pageSize)
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,
indexPath.row == timelineSegments[indexPath.section].count - 1 {
guard let older = older else { return }
@ -101,13 +169,14 @@ class TimelineTableViewController: EnhancedTableViewController {
mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older
self.mastodonController.cache.addAll(statuses: newStatuses)
let newRows = self.timelineSegments.last!.count..<(self.timelineSegments.last!.count + newStatuses.count)
let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.timelineSegments.count - 1) }
self.timelineSegments[self.timelineSegments.count - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
}
}
}
}
@ -126,6 +195,8 @@ class TimelineTableViewController: EnhancedTableViewController {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
// MARK: Interaction
@objc func refreshStatuses(_ sender: Any) {
guard let newer = newer else { return }
@ -133,25 +204,26 @@ class TimelineTableViewController: EnhancedTableViewController {
mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.newer = pagination?.newer
self.mastodonController.cache.addAll(statuses: newStatuses)
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
if let newer = pagination?.newer {
self.newer = newer
}
DispatchQueue.main.async {
let newIndexPaths = (0..<newStatuses.count).map {
IndexPath(row: $0, section: 0)
}
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
let newIndexPaths = (0..<newStatuses.count).map {
IndexPath(row: $0, section: 0)
}
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.refreshControl?.endRefreshing()
self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to the top)
self.tableView.scrollToRow(at: IndexPath(row: newStatuses.count, section: 0), at: .top, animated: false)
// maintain the current position in the list (don't scroll to the top)
self.tableView.scrollToRow(at: IndexPath(row: newStatuses.count, section: 0), at: .top, animated: false)
}
}
}
}
@ -175,7 +247,7 @@ extension TimelineTableViewController: StatusTableViewCellDelegate {
extension TimelineTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statusID(for: indexPath)) else { continue }
guard let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
@ -185,7 +257,10 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.cache.status(for: statusID(for: indexPath)) else { continue }
// 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)
for attachment in status.attachments {
ImageCache.attachments.cancelWithoutCallback(attachment.url)

View File

@ -11,31 +11,44 @@ import SafariServices
class EnhancedTableViewController: UITableViewController {
var prevScrollToTopOffset: CGPoint? = nil
private var prevScrollToTopOffset: CGPoint? = nil
private(set) var isCurrentlyScrollingToTop = false
private var prevScrollViewContentOffset: CGPoint?
private(set) var scrollViewDirection: CGFloat = 0
private var topOffset: CGPoint {
// when scrolled to top, the content offset is negative the height of the UI above the scroll view (i.e. the nav and status bars)
let windowScene = view.window!.windowScene!
let barOffset = -1 * (navigationController!.navigationBar.frame.height + windowScene.statusBarManager!.statusBarFrame.height)
// add one so it's not technically all the way at the top, and scrollViewWShouldScrollToTop is still called to trigger undo
return CGPoint(x: 0, y: barOffset + 1)
}
// MARK: Scroll View Delegate
override func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
if let offset = prevScrollToTopOffset {
tableView.setContentOffset(offset, animated: true)
prevScrollToTopOffset = nil
return false
} else {
prevScrollToTopOffset = tableView.contentOffset
tableView.setContentOffset(topOffset, animated: true)
isCurrentlyScrollingToTop = true
return true
}
return false
}
override func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
isCurrentlyScrollingToTop = false
// add one so it's not technically scrolled all the way to the top,
// otherwise there's no way of detecting a status bar press to scroll back down
tableView.contentOffset.y -= 0.5
}
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
prevScrollToTopOffset = nil
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let prev = prevScrollViewContentOffset {
scrollViewDirection = scrollView.contentOffset.y - prev.y
}
prevScrollViewContentOffset = scrollView.contentOffset
}
// MARK: Table View Delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell {
@ -72,8 +85,8 @@ extension EnhancedTableViewController {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if viewController is LargeImageViewController || viewController is GalleryViewController || viewController is SFSafariViewController {
self.present(viewController, animated: true)
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: self)
} else {
self.show(viewController, sender: nil)
}

View File

@ -35,6 +35,12 @@ class InteractivePushTransition: UIPercentDrivenInteractiveTransition {
interactivePushGestureRecognizer.edges = .right
interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
navigationController.view.addGestureRecognizer(interactivePushGestureRecognizer)
if #available(iOS 13.4, *) {
let trackpadGestureRecognizer = TrackpadScrollGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:)))
trackpadGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
navigationController.view.addGestureRecognizer(trackpadGestureRecognizer)
}
}
@objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) {

View File

@ -20,13 +20,17 @@ protocol MenuPreviewProvider {
}
protocol CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController)
}
extension MenuPreviewProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIAction] {
guard let mastodonController = mastodonController,
let account = mastodonController.cache.account(for: accountID) else { return [] }
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
return [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { (_) in
self.navigationDelegate?.compose(mentioning: account.acct)
@ -57,7 +61,7 @@ extension MenuPreviewProvider {
func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIAction] {
guard let mastodonController = mastodonController,
let status = mastodonController.cache.status(for: statusID) else { return [] }
let status = mastodonController.persistentContainer.status(for: statusID) else { return [] }
return [
createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { (_) in
self.navigationDelegate?.reply(to: statusID)
@ -76,3 +80,21 @@ extension MenuPreviewProvider {
}
}
extension LargeImageViewController: CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) {
presenter.present(self, animated: true)
}
}
extension GalleryViewController: CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) {
presenter.present(self, animated: true)
}
}
extension SFSafariViewController: CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) {
presenter.present(self, animated: true)
}
}

View File

@ -0,0 +1,24 @@
//
// TrackpadScreenEdgePanScrollGestureRecognizer.swift
// Tusker
//
// Created by Shadowfacts on 3/25/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
@available(iOS 13.4, *)
class TrackpadScrollGestureRecognizer: UIPanGestureRecognizer {
override init(target: Any?, action: Selector?) {
super.init(target: target, action: action)
self.allowedScrollTypesMask = .all
}
override func shouldReceive(_ event: UIEvent) -> Bool {
return event.type == .scroll
}
}

View File

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

View File

@ -151,29 +151,14 @@ extension TuskerNavigationDelegate where Self: UIViewController {
present(vc, animated: true)
}
private func sourceViewInfo(_ sourceView: UIImageView?) -> LargeImageViewController.SourceInfo? {
guard let sourceView = sourceView else { return nil }
var sourceFrame = sourceView.convert(sourceView.bounds, to: view)
if let scrollView = view as? UIScrollView {
let scale = scrollView.zoomScale
let width = sourceFrame.width * scale
let height = sourceFrame.height * scale
let x = sourceFrame.minX * scale - scrollView.contentOffset.x + scrollView.frame.minX
let y = sourceFrame.minY * scale - scrollView.contentOffset.y + scrollView.frame.minY
sourceFrame = CGRect(x: x, y: y, width: width, height: height)
}
return (image: sourceView.image, frame: sourceFrame, cornerRadius: sourceView.layer.cornerRadius)
}
func largeImage(_ image: UIImage, description: String?, sourceView: UIImageView) -> LargeImageViewController {
let vc = LargeImageViewController(image: image, description: description, sourceInfo: sourceViewInfo(sourceView))
let vc = LargeImageViewController(image: image, description: description, sourceView: sourceView)
vc.transitioningDelegate = self
return vc
}
func largeImage(gifData: Data, description: String?, sourceView: UIImageView) -> LargeImageViewController {
let vc = LargeImageViewController(image: UIImage(data: gifData)!, description: description, sourceInfo: sourceViewInfo(sourceView))
let vc = LargeImageViewController(image: UIImage(data: gifData)!, description: description, sourceView: sourceView)
vc.transitioningDelegate = self
vc.gifData = gifData
return vc
@ -189,7 +174,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
let vc = LoadingLargeImageViewController(url: url, cache: cache, imageDescription: description)
vc.animationSourceInfo = sourceViewInfo(sourceView)
vc.animationSourceView = sourceView
vc.transitioningDelegate = self
return vc
}
@ -199,8 +184,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
}
func gallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) -> GalleryViewController {
let sourcesInfo = sourceViews.map(sourceViewInfo)
let vc = GalleryViewController(attachments: attachments, sourcesInfo: sourcesInfo, startIndex: startIndex)
let vc = GalleryViewController(attachments: attachments, sourceViews: sourceViews, startIndex: startIndex)
vc.transitioningDelegate = self
return vc
}
@ -219,16 +203,15 @@ extension TuskerNavigationDelegate where Self: UIViewController {
}
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)") }
var customActivites: [UIActivity] = [OpenInSafariActivity()]
if let bookmarked = status.bookmarked {
customActivites.insert(bookmarked ? UnbookmarkStatusActivity() : BookmarkStatusActivity(), at: 0)
}
let bookmarked = status.bookmarked ?? false
customActivites.insert(bookmarked ? UnbookmarkStatusActivity() : BookmarkStatusActivity(), at: 0)
if status.account == apiController.account,
let pinned = status.pinned {
if apiController.account != nil, status.account.id == apiController.account.id {
let pinned = status.pinned ?? false
customActivites.insert(pinned ? UnpinStatusActivity() : PinStatusActivity(), at: 1)
}
@ -238,7 +221,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
}
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)
}

View File

@ -34,7 +34,7 @@ class AccountTableViewCell: UITableViewCell {
@objc func updateUIForPrefrences() {
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!)")
}
displayNameLabel.updateForAccountDisplayName(account: account)
@ -42,7 +42,7 @@ class AccountTableViewCell: UITableViewCell {
func updateUI(accountID: String) {
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)")
}

View File

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

View File

@ -12,7 +12,8 @@ import Gifu
import AVFoundation
protocol AttachmentViewDelegate: class {
func showAttachmentsGallery(startingAt index: Int)
func attachmentViewGallery(startingAt index: Int) -> UIViewController
func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
}
class AttachmentView: UIImageView, GIFAnimatable {
@ -55,6 +56,8 @@ class AttachmentView: UIImageView, GIFAnimatable {
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imagePressed)))
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
addInteraction(UIContextMenuInteraction(delegate: self))
}
@objc func preferencesChanged() {
@ -78,6 +81,8 @@ class AttachmentView: UIImageView, GIFAnimatable {
loadVideo()
case .audio:
loadAudio()
case .gifv:
loadGifv()
default:
preconditionFailure("invalid attachment type")
}
@ -108,9 +113,9 @@ class AttachmentView: UIImageView, GIFAnimatable {
let asset = AVURLAsset(url: attachmentURL)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: CMTime(seconds: 0, preferredTimescale: 1), actualTime: nil) else { return }
DispatchQueue.main.async {
guard self.attachment.url == attachmentURL else { return }
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self, self.attachment.url == attachmentURL else { return }
self.image = UIImage(cgImage: image)
}
}
@ -147,12 +152,69 @@ class AttachmentView: UIImageView, GIFAnimatable {
])
}
func loadGifv() {
let attachmentURL = self.attachment.url
let asset = AVURLAsset(url: attachmentURL)
DispatchQueue.global(qos: .userInitiated).async {
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self, self.attachment.url == attachmentURL else { return }
self.image = UIImage(cgImage: image)
}
}
let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill)
gifvView.translatesAutoresizingMaskIntoConstraints = false
addSubview(gifvView)
NSLayoutConstraint.activate([
gifvView.leadingAnchor.constraint(equalTo: leadingAnchor),
gifvView.trailingAnchor.constraint(equalTo: trailingAnchor),
gifvView.topAnchor.constraint(equalTo: topAnchor),
gifvView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
override func display(_ layer: CALayer) {
updateImageIfNeeded()
}
func showGallery() {
if let delegate = delegate {
let gallery = delegate.attachmentViewGallery(startingAt: index)
delegate.attachmentViewPresent(gallery, animated: true)
}
}
@objc func imagePressed() {
delegate?.showAttachmentsGallery(startingAt: index)
showGallery()
}
}
extension AttachmentView: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in
if self.attachment.kind == .image {
return AttachmentPreviewViewController(attachment: self.attachment)
} else if self.attachment.kind == .gifv {
let vc = GifvAttachmentViewController(attachment: self.attachment)
vc.preferredContentSize = self.image?.size ?? .zero
return vc
} else {
return self.delegate?.attachmentViewGallery(startingAt: self.index)
}
}, actionProvider: nil)
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
animator.addCompletion {
animator.preferredCommitStyle = .pop
if let gallery = animator.previewViewController as? GalleryViewController {
self.delegate?.attachmentViewPresent(gallery, animated: true)
} else {
self.showGallery()
}
}
}
}

View File

@ -11,7 +11,7 @@ import Pachyderm
class AttachmentsContainerView: UIView {
static let supportedAttachmentTypes = [Attachment.Kind.image, .video, .audio]
static let supportedAttachmentTypes = [Attachment.Kind.image, .video, .audio, .gifv]
weak var delegate: AttachmentViewDelegate?
@ -48,7 +48,7 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface
func updateUI(status: Status) {
func updateUI(status: StatusMO) {
self.statusID = status.id
attachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) }
@ -64,12 +64,20 @@ class AttachmentsContainerView: UIView {
switch attachments.count {
case 1:
let attachmentView = createAttachmentView(index: 0)
attachmentView.layer.cornerRadius = 5
attachmentView.layer.masksToBounds = true
fillView(attachmentView)
sendSubviewToBack(attachmentView)
accessibilityElements.append(attachmentView)
case 2:
let left = createAttachmentView(index: 0)
left.layer.cornerRadius = 5
left.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
left.layer.masksToBounds = true
let right = createAttachmentView(index: 1)
right.layer.cornerRadius = 5
right.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
right.layer.masksToBounds = true
let stack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left,
right
@ -83,8 +91,17 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(right)
case 3:
let left = createAttachmentView(index: 0)
left.layer.cornerRadius = 5
left.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
left.layer.masksToBounds = true
let topRight = createAttachmentView(index: 1)
topRight.layer.cornerRadius = 5
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let bottomRight = createAttachmentView(index: 2)
bottomRight.layer.cornerRadius = 5
bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner
bottomRight.layer.masksToBounds = true
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left,
createAttachmentsStack(axis: .vertical, arrangedSubviews: [
@ -103,13 +120,25 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(bottomRight)
case 4:
let topLeft = createAttachmentView(index: 0)
topLeft.layer.cornerRadius = 5
topLeft.layer.maskedCorners = .layerMinXMinYCorner
topLeft.layer.masksToBounds = true
let bottomLeft = createAttachmentView(index: 2)
bottomLeft.layer.cornerRadius = 5
bottomLeft.layer.maskedCorners = .layerMinXMaxYCorner
bottomLeft.layer.masksToBounds = true
let left = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topLeft,
bottomLeft
])
let topRight = createAttachmentView(index: 1)
topRight.layer.cornerRadius = 5
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let bottomRight = createAttachmentView(index: 3)
bottomRight.layer.cornerRadius = 5
bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner
bottomRight.layer.masksToBounds = true
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left,
createAttachmentsStack(axis: .vertical, arrangedSubviews: [
@ -135,6 +164,9 @@ class AttachmentsContainerView: UIView {
moreView.translatesAutoresizingMaskIntoConstraints = false
moreView.isUserInteractionEnabled = true
moreView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(moreViewTapped)))
moreView.layer.cornerRadius = 5
moreView.layer.maskedCorners = .layerMaxXMaxYCorner
moreView.layer.masksToBounds = true
let moreLabel = UILabel()
moreLabel.text = "\(attachments.count - 3) more..."
moreLabel.textColor = .secondaryLabel
@ -144,12 +176,21 @@ class AttachmentsContainerView: UIView {
moreView.accessibilityLabel = moreLabel.text
let topLeft = createAttachmentView(index: 0)
topLeft.layer.cornerRadius = 5
topLeft.layer.maskedCorners = .layerMinXMinYCorner
topLeft.layer.masksToBounds = true
let bottomLeft = createAttachmentView(index: 2)
bottomLeft.layer.cornerRadius = 5
bottomLeft.layer.maskedCorners = .layerMinXMaxYCorner
bottomLeft.layer.masksToBounds = true
let left = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topLeft,
bottomLeft
])
let topRight = createAttachmentView(index: 1)
topRight.layer.cornerRadius = 5
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left,
createAttachmentsStack(axis: .vertical, arrangedSubviews: [
@ -325,7 +366,10 @@ class AttachmentsContainerView: UIView {
@objc func moreViewTapped() {
guard attachments.count > 4 else { return }
// the more view shows up in place of the fourth attachemtn view, show tapping it should start at the fourth attachment
delegate?.showAttachmentsGallery(startingAt: 3)
if let delegate = delegate {
let gallery = delegate.attachmentViewGallery(startingAt: 3)
delegate.attachmentViewPresent(gallery, animated: true)
}
}
}

View File

@ -0,0 +1,49 @@
//
// GifvAttachmentView.swift
// Tusker
//
// Created by Shadowfacts on 5/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import AVFoundation
class GifvAttachmentView: UIView {
override class var layerClass: AnyClass {
return AVPlayerLayer.self
}
private var playerLayer: AVPlayerLayer {
layer as! AVPlayerLayer
}
private let item: AVPlayerItem
private let player: AVPlayer
init(asset: AVAsset, gravity: AVLayerVideoGravity) {
item = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: item)
super.init(frame: .zero)
playerLayer.player = player
playerLayer.videoGravity = gravity
player.play()
NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func restartItem() {
item.seek(to: .zero) { (success) in
guard success else { return }
self.player.play()
}
}
}

View File

@ -39,7 +39,7 @@ class ComposeStatusReplyView: UIView {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
}
func updateUI(for status: Status) {
func updateUI(for status: StatusMO) {
displayNameLabel.updateForAccountDisplayName(account: status.account)
usernameLabel.text = "@\(status.account.acct)"
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) {
// 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)
if let (link, range) = getLinkAtPoint(location) {
let text = (self.text as NSString).substring(with: range)

View File

@ -34,6 +34,9 @@ class DraftTableViewCell: UITableViewCell {
attachmentsStackView.addArrangedSubview(imageView)
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
imageView.backgroundColor = .secondarySystemBackground
imageView.contentMode = .scaleAspectFill
switch attachment.data {
case let .asset(asset):
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
@ -44,6 +47,10 @@ class DraftTableViewCell: UITableViewCell {
case .video(_):
// videos aren't saved to drafts, so this is unreachable
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 {
func updateForAccountDisplayName(account: Account) {
if Preferences.shared.hideCustomEmojiInUsernames {
self.text = account.displayName
self.removeEmojis()
} else {
self.text = account.displayName
self.setEmojis(account.emojis, identifier: account.id)
}
}
func updateForAccountDisplayName(account: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
self.text = account.displayNameWithoutCustomEmoji
self.removeEmojis()

View File

@ -16,6 +16,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var actionImageView: UIImageView!
@IBOutlet weak var verticalStackView: UIStackView!
@IBOutlet weak var actionAvatarStackView: UIStackView!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var actionLabel: UILabel!
@ -38,7 +39,8 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
@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)
for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews {
@ -52,7 +54,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
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!
self.statusID = status.id
@ -67,9 +69,10 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
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() }
var imageViews = [UIImageView]()
for account in people {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
@ -83,11 +86,18 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
}
actionAvatarStackView.addArrangedSubview(imageView)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: 30),
imageView.heightAnchor.constraint(equalToConstant: 30)
])
imageViews.append(imageView)
// don't add more avatars if they would overflow or squeeze the timestamp label
let avatarViewsWidth = 30 * CGFloat(imageViews.count)
let avatarMarginsWidth = 4 * CGFloat(max(0, imageViews.count - 1))
let maxAvatarStackWidth = verticalStackView.bounds.width - timestampLabel.bounds.width - 8
let remainingWidth = maxAvatarStackWidth - avatarViewsWidth - avatarMarginsWidth
if remainingWidth < 34 {
break
}
}
NSLayoutConstraint.activate(imageViews.map { $0.widthAnchor.constraint(equalTo: $0.heightAnchor) })
updateTimestamp()
@ -98,8 +108,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
func updateTimestamp() {
guard let id = group.notificationIDs.first,
let notification = mastodonController.cache.notification(for: id) else {
guard let notification = group.notifications.first else {
fatalError("Missing cached notification")
}
@ -126,7 +135,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
}
func updateActionLabel(people: [Account]) {
func updateActionLabel(people: [AccountMO]) {
let verb: String
switch group.kind {
case .favourite:
@ -138,13 +147,14 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
let peopleStr: String
// todo: figure out how to localize this
// todo: update to use managed objects
switch people.count {
case 1:
peopleStr = people.first!.displayOrUserName
peopleStr = people.first!.displayName
case 2:
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName
peopleStr = people.first!.displayName + " and " + people.last!.displayName
default:
peopleStr = people.dropLast().map { $0.displayOrUserName }.joined(separator: ", ") + ", and " + people.last!.displayOrUserName
peopleStr = people.dropLast().map { $0.displayName }.joined(separator: ", ") + ", and " + people.last!.displayName
}
actionLabel.text = "\(verb) by \(peopleStr)"
}
@ -162,7 +172,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
func didSelectCell() {
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 action: StatusActionAccountListTableViewController.ActionType
switch notifications.first!.kind {
@ -183,7 +193,7 @@ extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
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 action: StatusActionAccountListTableViewController.ActionType
switch notifications.first!.kind {

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@ -19,19 +19,19 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="hld-yu-Rmi">
<rect key="frame" x="74" y="11" width="230" height="153"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="hTQ-P4-gOO">
<stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="hTQ-P4-gOO">
<rect key="frame" x="0.0" y="0.0" width="230" height="30"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="b7l-YW-nQY">
<rect key="frame" x="0.0" y="0.0" width="205.5" height="30"/>
<stackView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" ambiguous="YES" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="b7l-YW-nQY">
<rect key="frame" x="0.0" y="0.0" width="189.5" height="30"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="9uh-oo-JSM"/>
</constraints>
</stackView>
<view contentMode="scaleToFill" horizontalHuggingPriority="249" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5Ef-5g-b23">
<rect key="frame" x="205.5" y="0.0" width="0.0" height="30"/>
<rect key="frame" x="197.5" y="0.0" width="0.0" height="30"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JN0-Bf-3qx">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="752" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JN0-Bf-3qx">
<rect key="frame" x="205.5" y="0.0" width="24.5" height="30"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
@ -78,6 +78,7 @@
<outlet property="actionLabel" destination="fkn-Gk-ngr" id="bBG-a8-m5G"/>
<outlet property="statusContentLabel" destination="lc7-zZ-HrZ" id="jgT-LU-rXt"/>
<outlet property="timestampLabel" destination="JN0-Bf-3qx" id="Jlo-f6-DAi"/>
<outlet property="verticalStackView" destination="hld-yu-Rmi" id="jvu-1u-Ok3"/>
</connections>
<point key="canvasLocation" x="-394.20289855072468" y="56.584821428571423"/>
</tableViewCell>

View File

@ -34,7 +34,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
}
@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)
for case let imageView as UIImageView in avatarStackView.arrangedSubviews {
@ -45,7 +45,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
func updateUI(group: NotificationGroup) {
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)
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
let peopleStr: String
switch people.count {
case 1:
peopleStr = people.first!.displayOrUserName
peopleStr = people.first!.displayName
case 2:
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName
peopleStr = people.first!.displayName + " and " + people.last!.displayName
default:
peopleStr = people.dropLast().map { $0.displayOrUserName }.joined(separator: ", ") + ", and " + people.last!.displayOrUserName
peopleStr = people.dropLast().map { $0.displayName }.joined(separator: ", ") + ", and " + people.last!.displayName
}
actionLabel.text = "Followed by \(peopleStr)"
}
func updateTimestamp() {
guard let id = group.notificationIDs.first,
let notification = mastodonController.cache.notification(for: id) else {
guard let notification = group.notifications.first else {
fatalError("Missing cached notification")
}
@ -127,14 +127,14 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
extension FollowNotificationGroupTableViewCell: SelectableTableViewCell {
func didSelectCell() {
let people = group.notificationIDs.compactMap(mastodonController.cache.notification(for:)).map { $0.account.id }
switch people.count {
let accountIDs = group.notifications.map { $0.account.id }
switch accountIDs.count {
case 0:
return
case 1:
delegate?.selected(account: people.first!)
delegate?.selected(account: accountIDs.first!)
default:
delegate?.showFollowedByList(accountIDs: people)
delegate?.showFollowedByList(accountIDs: accountIDs)
}
}
}
@ -144,7 +144,7 @@ extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
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: {
if accountIDs.count == 1 {
return ProfileTableViewController(accountID: accountIDs.first!, mastodonController: mastodonController)

View File

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

View File

@ -52,6 +52,10 @@ class ProfileHeaderTableViewCell: UITableViewCell {
maskLayer.path = CGPath(ellipseIn: moreButtonVisualEffectView.bounds, transform: nil)
moreButtonVisualEffectView.layer.mask = maskLayer
if #available(iOS 13.4, *) {
moreButtonVisualEffectView.addInteraction(UIPointerInteraction(delegate: self))
}
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
@ -59,7 +63,7 @@ class ProfileHeaderTableViewCell: UITableViewCell {
guard accountID != self.accountID else { return }
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()
@ -84,55 +88,50 @@ class ProfileHeaderTableViewCell: UITableViewCell {
noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis)
// don't show relationship label for the user's own account
if accountID != mastodonController.account.id {
// don't show relationship label for the user's own account
if let relationship = mastodonController.cache.relationship(for: accountID) {
followsYouLabel.isHidden = !relationship.followedBy
} else {
mastodonController.cache.relationship(for: accountID) { relationship in
let request = Client.getRelationships(accounts: [accountID])
mastodonController.run(request) { (response) in
if case let .success(results, _) = response, let relationship = results.first {
DispatchQueue.main.async {
self.followsYouLabel.isHidden = !(relationship?.followedBy ?? false)
self.followsYouLabel.isHidden = !relationship.followedBy
}
}
}
}
if let fields = account.fields, !fields.isEmpty {
fieldsStackView.isHidden = false
fieldsStackView.isHidden = account.fields.isEmpty
fieldsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for field in fields {
let nameLabel = UILabel()
nameLabel.text = field.name
nameLabel.font = .boldSystemFont(ofSize: 17)
nameLabel.textAlignment = .right
nameLabel.numberOfLines = 0
fieldNamesStackView.addArrangedSubview(nameLabel)
fieldsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for field in account.fields {
let nameLabel = UILabel()
nameLabel.text = field.name
nameLabel.font = .boldSystemFont(ofSize: 17)
nameLabel.textAlignment = .right
nameLabel.numberOfLines = 0
fieldNamesStackView.addArrangedSubview(nameLabel)
let valueTextView = ContentTextView()
valueTextView.isSelectable = false
valueTextView.font = .systemFont(ofSize: 17)
valueTextView.setTextFromHtml(field.value)
valueTextView.setEmojis(account.emojis)
valueTextView.textAlignment = .left
valueTextView.awakeFromNib()
valueTextView.navigationDelegate = delegate
fieldValuesStack.addArrangedSubview(valueTextView)
}
} else {
fieldsStackView.isHidden = true
let valueTextView = ContentTextView()
valueTextView.isSelectable = false
valueTextView.font = .systemFont(ofSize: 17)
valueTextView.setTextFromHtml(field.value)
valueTextView.setEmojis(account.emojis)
valueTextView.textAlignment = .left
valueTextView.awakeFromNib()
valueTextView.navigationDelegate = delegate
fieldValuesStack.addArrangedSubview(valueTextView)
}
if accountUpdater == nil {
accountUpdater = mastodonController.cache.accountSubject
.filter { [unowned self] in $0.id == self.accountID }
accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0 == self.accountID }
.receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateUI(for: $0.id) }
.sink { [unowned self] in self.updateUI(for: $0) }
}
}
@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)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
@ -149,13 +148,22 @@ class ProfileHeaderTableViewCell: UITableViewCell {
}
@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)
}
@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)
}
}
extension ProfileHeaderTableViewCell: UIPointerInteractionDelegate {
@available(iOS 13.4, *)
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: moreButtonVisualEffectView)
let rect = CGRect(x: moreButtonVisualEffectView.frame.minX - 4, y: moreButtonVisualEffectView.frame.minY - 4, width: moreButtonVisualEffectView.frame.width + 8, height: moreButtonVisualEffectView.frame.height + 8)
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect, radius: 4))
}
}

View File

@ -69,11 +69,6 @@ class BaseStatusTableViewCell: UITableViewCell {
private var statusUpdater: Cancellable?
private var accountUpdater: Cancellable?
deinit {
statusUpdater?.cancel()
accountUpdater?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
@ -84,8 +79,6 @@ class BaseStatusTableViewCell: UITableViewCell {
avatarImageView.layer.masksToBounds = true
attachmentsView.delegate = self
attachmentsView.layer.cornerRadius = 5
attachmentsView.layer.masksToBounds = true
collapseButton.layer.masksToBounds = true
collapseButton.layer.cornerRadius = 5
@ -98,24 +91,32 @@ class BaseStatusTableViewCell: UITableViewCell {
open func createObserversIfNecessary() {
if statusUpdater == nil {
statusUpdater = mastodonController.cache.statusSubject
.filter { [unowned self] in $0.id == self.statusID }
statusUpdater = mastodonController.persistentContainer.statusSubject
.filter { [unowned self] in $0 == self.statusID }
.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 {
accountUpdater = mastodonController.cache.accountSubject
.filter { [unowned self] in $0.id == self.accountID }
accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0 == self.accountID }
.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) {
createObserversIfNecessary()
guard let status = mastodonController.cache.status(for: statusID) else {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Missing cached status")
}
self.statusID = statusID
@ -163,9 +164,9 @@ class BaseStatusTableViewCell: UITableViewCell {
}
}
func updateStatusState(status: Status) {
favorited = status.favourited ?? false
reblogged = status.reblogged ?? false
func updateStatusState(status: StatusMO) {
favorited = status.favourited
reblogged = status.reblogged
if favorited {
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
@ -179,22 +180,22 @@ class BaseStatusTableViewCell: UITableViewCell {
}
}
func updateUI(account: Account) {
func updateUI(account: AccountMO) {
usernameLabel.text = "@\(account.acct)"
avatarImageView.image = nil
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == account.id else { return }
DispatchQueue.main.async {
guard let self = self, let data = data, self.accountID == account.id else { return }
self.avatarImageView.image = UIImage(data: data)
}
}
}
@objc func updateUIForPreferences() {
guard let mastodonController = mastodonController, let account = mastodonController.cache.account(for: accountID) else { return }
guard let mastodonController = mastodonController, let account = mastodonController.persistentContainer.account(for: accountID) else { return }
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account)
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.cache.status(for: statusID)?.sensitive ?? false)
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false)
}
override func prepareForReuse() {
@ -250,18 +251,18 @@ class BaseStatusTableViewCell: UITableViewCell {
}
@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
favorited = !favorited
let realStatus: Status = status.reblog ?? status
let request = (favorited ? Status.favourite : Status.unfavourite)(realStatus)
let realStatus = status.reblog ?? status
let request = (favorited ? Status.favourite : Status.unfavourite)(realStatus.id)
mastodonController.run(request) { response in
DispatchQueue.main.async {
if case let .success(newStatus, _) = response {
self.favorited = newStatus.favourited ?? false
self.mastodonController.cache.add(status: newStatus)
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else {
self.favorited = oldValue
@ -275,18 +276,18 @@ class BaseStatusTableViewCell: UITableViewCell {
}
@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
reblogged = !reblogged
let realStatus: Status = status.reblog ?? status
let request = (reblogged ? Status.reblog : Status.unreblog)(realStatus)
let realStatus = status.reblog ?? status
let request = (reblogged ? Status.reblog : Status.unreblog)(realStatus.id)
mastodonController.run(request) { response in
DispatchQueue.main.async {
if case let .success(newStatus, _) = response {
self.reblogged = newStatus.reblogged ?? false
self.mastodonController.cache.add(status: newStatus)
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else {
self.reblogged = oldValue
@ -312,10 +313,14 @@ class BaseStatusTableViewCell: UITableViewCell {
}
extension BaseStatusTableViewCell: AttachmentViewDelegate {
func showAttachmentsGallery(startingAt index: Int) {
guard let status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
func attachmentViewGallery(startingAt index: Int) -> UIViewController {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
delegate?.showGallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
return delegate!.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
}
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
delegate?.show(vc)
}
}
@ -329,19 +334,6 @@ extension BaseStatusTableViewCell: MenuPreviewProvider {
content: { ProfileTableViewController(accountID: self.accountID, mastodonController: mastodonController) },
actions: { self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) }
)
} else if attachmentsView.frame.contains(location) {
let attachmentsViewLocation = attachmentsView.convert(location, from: self)
if let attachmentView = attachmentsView.attachmentViews.allObjects.first(where: { $0.frame.contains(attachmentsViewLocation) }) {
return (
content: {
let attachments = self.attachmentsView.attachments!
let sourceViews = attachments.map(self.attachmentsView.getAttachmentView(for:))
let startIndex = sourceViews.firstIndex(of: attachmentView)!
return self.delegate?.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: startIndex)
},
actions: { [] }
)
}
}
return self.getStatusCellPreviewProviders(for: location, sourceViewController: sourceViewController)
}

View File

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

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16092.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16082.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@ -144,7 +144,7 @@
<stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="3Bg-XP-d13">
<rect key="frame" x="0.0" y="257" width="343" height="18"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2cc-lE-AdG">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2cc-lE-AdG">
<rect key="frame" x="0.0" y="0.0" width="21" height="18"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
@ -152,7 +152,7 @@
<action selector="replyPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="RxZ-zv-lkN"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="DhN-rJ-jdA">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="DhN-rJ-jdA">
<rect key="frame" x="107" y="0.0" width="22" height="18"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<state key="normal" image="star.fill" catalog="system"/>
@ -160,7 +160,7 @@
<action selector="favoritePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="NCA-iR-VMt"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="GUG-f7-Hdy">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="GUG-f7-Hdy">
<rect key="frame" x="215.5" y="0.0" width="22.5" height="18"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<state key="normal" image="repeat" catalog="system"/>
@ -168,7 +168,7 @@
<action selector="reblogPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="iIu-Vv-U0I"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Ujo-Ap-dmK">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Ujo-Ap-dmK">
<rect key="frame" x="324" y="0.0" width="19" height="18"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<state key="normal" image="ellipsis" catalog="system"/>

View File

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

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16092.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16082.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@ -135,7 +135,7 @@
<stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" distribution="equalSpacing" alignment="bottom" translatesAutoresizingMaskIntoConstraints="NO" id="Zlb-yt-NTw">
<rect key="frame" x="0.0" y="202" width="343" height="22"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
<rect key="frame" x="0.0" y="0.0" width="21" height="22"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
@ -143,7 +143,7 @@
<action selector="replyPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="ybz-3W-jAa"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
<rect key="frame" x="107" y="0.0" width="22" height="22"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<state key="normal" image="star.fill" catalog="system"/>
@ -151,7 +151,7 @@
<action selector="favoritePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="8Q8-Rz-k02"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
<rect key="frame" x="215.5" y="0.0" width="22.5" height="22"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<state key="normal" image="repeat" catalog="system"/>
@ -159,7 +159,7 @@
<action selector="reblogPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="Wa2-ZA-TBo"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
<rect key="frame" x="324" y="0.0" width="19" height="22"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<state key="normal" image="ellipsis" catalog="system"/>

View File

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

40
Tusker/WeakArray.swift Normal file
View File

@ -0,0 +1,40 @@
//
// WeakArray.swift
// Tusker
//
// Created by Shadowfacts on 3/25/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
fileprivate class WeakWrapper<T: AnyObject> {
weak var value: T?
init(_ value: T?) {
self.value = value
}
}
struct WeakArray<Element: AnyObject>: Collection {
private var array: [WeakWrapper<Element>]
var startIndex: Int { array.startIndex }
var endIndex: Int { array.endIndex }
init(_ elements: [Element]) {
array = elements.map { WeakWrapper($0) }
}
init(_ elements: [Element?]) {
array = elements.map { WeakWrapper($0) }
}
subscript(_ index: Int) -> Element? {
return array[index].value
}
func index(after i: Int) -> Int {
return array.index(after: i)
}
}

View File

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