Compare commits

..

No commits in common. "cf48e4e973e38ae00c580e7aef17ee2ceff706a5" and "4ce8de280eb36ddd3ce84acc237d74ce6a8851f4" have entirely different histories.

84 changed files with 743 additions and 2314 deletions

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class Mention: Codable { public class Mention: Decodable {
public let url: URL public let url: URL
public let username: String public let username: String
public let acct: String public let acct: String

View File

@ -1,33 +0,0 @@
//
// 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,35 +0,0 @@
//
// 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,26 +30,20 @@ class MastodonController {
} }
} }
static func resetAll() { private(set) lazy var cache = MastodonCache(mastodonController: self)
all = [:]
}
private let transient: Bool
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL let instanceURL: URL
var accountInfo: LocalData.UserAccountInfo? private(set) var accountInfo: LocalData.UserAccountInfo?
let client: Client! let client: Client!
var account: Account! var account: Account!
var instance: Instance! var instance: Instance!
init(instanceURL: URL, transient: Bool = false) { init(instanceURL: URL) {
self.instanceURL = instanceURL self.instanceURL = instanceURL
self.accountInfo = nil self.accountInfo = nil
self.client = Client(baseURL: instanceURL) self.client = Client(baseURL: instanceURL)
self.transient = transient
} }
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) { func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
@ -88,16 +82,8 @@ class MastodonController {
run(request) { response in run(request) { response in
guard case let .success(account, _) = response else { fatalError() } guard case let .success(account, _) = response else { fatalError() }
self.account = account self.account = account
self.persistentContainer.backgroundContext.perform { self.cache.add(account: account)
if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) { completion?(account)
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

@ -1,105 +0,0 @@
//
// 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

@ -1,190 +0,0 @@
//
// 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

@ -1,134 +0,0 @@
//
// 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

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

View File

@ -1,33 +0,0 @@
//
// 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

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

177
Tusker/MastodonCache.swift Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,7 +72,7 @@ class ComposeViewController: UIViewController {
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.inReplyToID = inReplyToID self.inReplyToID = inReplyToID
if let inReplyToID = inReplyToID, let inReplyTo = mastodonController.persistentContainer.status(for: inReplyToID) { if let inReplyToID = inReplyToID, let inReplyTo = mastodonController.cache.status(for: inReplyToID) {
accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct } accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct }
} else { } else {
accountsToMention = [] accountsToMention = []
@ -164,20 +164,17 @@ class ComposeViewController: UIViewController {
} }
if let inReplyToID = inReplyToID { if let inReplyToID = inReplyToID {
if let status = mastodonController.persistentContainer.status(for: inReplyToID) { if let status = mastodonController.cache.status(for: inReplyToID) {
updateInReplyTo(inReplyTo: status) updateInReplyTo(inReplyTo: status)
} else { } else {
let loadingVC = LoadingViewController() let loadingVC = LoadingViewController()
embedChild(loadingVC) embedChild(loadingVC)
let request = Client.getStatus(id: inReplyToID) mastodonController.cache.status(for: inReplyToID) { (status) in
mastodonController.run(request) { (response) in guard let status = status else { return }
guard case let .success(status, _) = response else { return } DispatchQueue.main.async {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true) { (status) in self.updateInReplyTo(inReplyTo: status)
DispatchQueue.main.async { loadingVC.removeViewAndController()
self.updateInReplyTo(inReplyTo: status)
loadingVC.removeViewAndController()
}
} }
} }
} }
@ -189,7 +186,7 @@ class ComposeViewController: UIViewController {
} }
} }
func updateInReplyTo(inReplyTo: StatusMO) { func updateInReplyTo(inReplyTo: Status) {
visibility = inReplyTo.visibility visibility = inReplyTo.visibility
if Preferences.shared.contentWarningCopyMode == .doNotCopy { if Preferences.shared.contentWarningCopyMode == .doNotCopy {
contentWarningEnabled = false contentWarningEnabled = false
@ -216,8 +213,7 @@ class ComposeViewController: UIViewController {
replyAvatarImageViewTopConstraint!.isActive = true replyAvatarImageViewTopConstraint!.isActive = true
inReplyToContainer.isHidden = false inReplyToContainer.isHidden = false
// todo: update to use managed objects inReplyToLabel.text = "In reply to \(inReplyTo.account.displayOrUserName)"
inReplyToLabel.text = "In reply to \(inReplyTo.account.displayName)"
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -237,7 +233,7 @@ class ComposeViewController: UIViewController {
return [] return []
} }
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in return StatusFormat.allCases.map { (format) in
let item: UIBarButtonItem let item: UIBarButtonItem
if let image = format.image { if let image = format.image {
item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:))) item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
@ -252,14 +248,6 @@ class ComposeViewController: UIViewController {
item.accessibilityLabel = format.accessibilityLabel item.accessibilityLabel = format.accessibilityLabel
return item 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) { @objc func adjustForKeyboard(notification: NSNotification) {
@ -473,7 +461,7 @@ class ComposeViewController: UIViewController {
self.mastodonController.run(request) { (response) in self.mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else { fatalError() } guard case let .success(status, _) = response else { fatalError() }
self.postedStatus = status self.postedStatus = status
// self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true) self.mastodonController.cache.add(status: status)
if let draft = self.currentDraft { if let draft = self.currentDraft {
DraftsManager.shared.remove(draft) DraftsManager.shared.remove(draft)
@ -484,8 +472,8 @@ class ComposeViewController: UIViewController {
self.dismiss(animated: true) self.dismiss(animated: true)
// todo: this doesn't work // todo: this doesn't work
// let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController) let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController)
// self.show(conversationVC, sender: self) self.show(conversationVC, sender: self)
self.xcbSession?.complete(with: .success, additionalData: [ self.xcbSession?.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString, "statusURL": status.url?.absoluteString,

View File

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

View File

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

View File

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

View File

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

View File

@ -10,30 +10,12 @@ import UIKit
import Gifu import Gifu
protocol LargeImageAnimatableViewController: UIViewController { protocol LargeImageAnimatableViewController: UIViewController {
var animationSourceView: UIImageView? { get } var animationSourceInfo: LargeImageViewController.SourceInfo? { get }
var animationImage: UIImage? { get } var animationImage: UIImage? { get }
var animationGifData: Data? { get } var animationGifData: Data? { get }
var dismissInteractionController: LargeImageInteractionController? { 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 { class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
@ -50,35 +32,25 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
containerView.addSubview(toVC.view) containerView.addSubview(toVC.view)
let finalVCFrame = transitionContext.finalFrame(for: toVC) let finalVCFrame = transitionContext.finalFrame(for: toVC)
guard let sourceView = toVC.animationSourceView, guard let sourceInfo = toVC.animationSourceInfo,
let sourceFrame = toVC.sourceViewFrame(in: fromVC.view),
let image = toVC.animationImage else { let image = toVC.animationImage else {
toVC.view.frame = finalVCFrame toVC.view.frame = finalVCFrame
transitionContext.completeTransition(!transitionContext.transitionWasCancelled) transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return 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 let imageView = GIFImageView(frame: sourceInfo.frame)
sourceView.alpha = 0
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 imageView.image = image
if let gifData = toVC.animationGifData { if let gifData = toVC.animationGifData {
imageView.animate(withGIFData: gifData) imageView.animate(withGIFData: gifData)
} }
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = sourceView.layer.cornerRadius imageView.layer.cornerRadius = sourceInfo.cornerRadius
imageView.layer.maskedCorners = sourceView.layer.maskedCorners
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
let blackView = UIView(frame: finalVCFrame) let blackView = UIView(frame: finalVCFrame)
@ -107,9 +79,6 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
fromVC.view.isHidden = false fromVC.view.isHidden = false
blackView.removeFromSuperview() blackView.removeFromSuperview()
imageView.removeFromSuperview() imageView.removeFromSuperview()
sourceView.alpha = 1
transitionContext.completeTransition(!transitionContext.transitionWasCancelled) transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}) })
} }

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
private let followRequestCell = "followRequestCell" private let followRequestCell = "followRequestCell"
private let unknownCell = "unknownCell" private let unknownCell = "unknownCell"
weak var mastodonController: MastodonController! let mastodonController: MastodonController
let excludedTypes: [Pachyderm.Notification.Kind] let excludedTypes: [Pachyderm.Notification.Kind]
let groupTypes = [Notification.Kind.favourite, .reblog, .follow] let groupTypes = [Notification.Kind.favourite, .reblog, .follow]
@ -63,13 +63,15 @@ class NotificationsTableViewController: EnhancedTableViewController {
self.groups.append(contentsOf: groups) self.groups.append(contentsOf: groups)
self.mastodonController.cache.addAll(notifications: notifications)
self.mastodonController.cache.addAll(statuses: notifications.compactMap { $0.status })
self.mastodonController.cache.addAll(accounts: notifications.map { $0.account })
self.newer = pagination?.newer self.newer = pagination?.newer
self.older = pagination?.older self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(notifications: notifications) { DispatchQueue.main.async {
DispatchQueue.main.async { self.tableView.reloadData()
self.tableView.reloadData()
}
} }
} }
} }
@ -90,7 +92,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
switch group.kind { switch group.kind {
case .mention: case .mention:
guard let notification = group.notifications.first, guard let notification = mastodonController.cache.notification(for: group.notificationIDs.first!),
let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else { let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else {
fatalError() fatalError()
} }
@ -111,7 +113,7 @@ class NotificationsTableViewController: EnhancedTableViewController {
return cell return cell
case .followRequest: case .followRequest:
guard let notification = group.notifications.first, guard let notification = mastodonController.cache.notification(for: group.notificationIDs.first!),
let cell = tableView.dequeueReusableCell(withIdentifier: followRequestCell, for: indexPath) as? FollowRequestNotificationTableViewCell else { fatalError() } let cell = tableView.dequeueReusableCell(withIdentifier: followRequestCell, for: indexPath) as? FollowRequestNotificationTableViewCell else { fatalError() }
cell.delegate = self cell.delegate = self
cell.updateUI(notification: notification) cell.updateUI(notification: notification)
@ -127,33 +129,6 @@ class NotificationsTableViewController: EnhancedTableViewController {
// MARK: - Table view delegate // MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// 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 { if indexPath.row == groups.count - 1 {
guard let older = older else { return } guard let older = older else { return }
@ -167,14 +142,16 @@ class NotificationsTableViewController: EnhancedTableViewController {
IndexPath(row: $0, section: 0) IndexPath(row: $0, section: 0)
} }
self.groups.append(contentsOf: groups) self.groups.append(contentsOf: groups)
self.mastodonController.cache.addAll(notifications: newNotifications)
self.mastodonController.cache.addAll(statuses: newNotifications.compactMap { $0.status })
self.mastodonController.cache.addAll(accounts: newNotifications.map { $0.account })
self.older = pagination?.older self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) { DispatchQueue.main.async {
DispatchQueue.main.async { UIView.performWithoutAnimation {
UIView.performWithoutAnimation { self.tableView.insertRows(at: newIndexPaths, with: .automatic)
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
} }
} }
} }
@ -218,8 +195,8 @@ class NotificationsTableViewController: EnhancedTableViewController {
func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) { func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
let group = DispatchGroup() let group = DispatchGroup()
groups[indexPath.row].notifications groups[indexPath.row].notificationIDs
.map { Pachyderm.Notification.dismiss(id: $0.id) } .map(Pachyderm.Notification.dismiss(id:))
.forEach { (request) in .forEach { (request) in
group.enter() group.enter()
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
@ -244,24 +221,26 @@ class NotificationsTableViewController: EnhancedTableViewController {
self.groups.insert(contentsOf: groups, at: 0) self.groups.insert(contentsOf: groups, at: 0)
self.mastodonController.cache.addAll(notifications: newNotifications)
self.mastodonController.cache.addAll(statuses: newNotifications.compactMap { $0.status })
self.mastodonController.cache.addAll(accounts: newNotifications.map { $0.account })
if let newer = pagination?.newer { if let newer = pagination?.newer {
self.newer = newer self.newer = newer
} }
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) { DispatchQueue.main.async {
DispatchQueue.main.async { let newIndexPaths = (0..<groups.count).map {
let newIndexPaths = (0..<groups.count).map { IndexPath(row: $0, section: 0)
IndexPath(row: $0, section: 0)
}
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to top)
self.tableView.scrollToRow(at: IndexPath(row: newNotifications.count, section: 0), at: .top, animated: false)
} }
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to top)
self.tableView.scrollToRow(at: IndexPath(row: newNotifications.count, section: 0), at: .top, animated: false)
} }
} }
} }
@ -280,8 +259,8 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
extension NotificationsTableViewController: UITableViewDataSourcePrefetching { extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
for notification in groups[indexPath.row].notifications { for notificationID in groups[indexPath.row].notificationIDs {
// todo: this account object could be stale guard let notification = mastodonController.cache.notification(for: notificationID) else { continue }
_ = ImageCache.avatars.get(notification.account.avatar, completion: nil) _ = ImageCache.avatars.get(notification.account.avatar, completion: nil)
} }
} }
@ -289,7 +268,8 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
for notification in groups[indexPath.row].notifications { for notificationID in groups[indexPath.row].notificationIDs {
guard let notification = mastodonController.cache.notification(for: notificationID) else { continue }
ImageCache.avatars.cancelWithoutCallback(notification.account.avatar) ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
} }
} }

View File

@ -68,15 +68,9 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
let authCode = item.value else { return } let authCode = item.value else { return }
mastodonController.authorize(authorizationCode: authCode) { (accessToken) in mastodonController.authorize(authorizationCode: authCode) { (accessToken) in
// 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 mastodonController.getOwnAccount { (account) in
DispatchQueue.main.async { 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) let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: account.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
} }

View File

@ -15,7 +15,6 @@ struct AdvancedPrefsView : View {
List { List {
formattingSection formattingSection
automationSection automationSection
cachingSection
}.listStyle(GroupedListStyle()) }.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Advanced")) .navigationBarTitle(Text("Advanced"))
} }
@ -42,27 +41,6 @@ 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 { extension StatusContentType {

View File

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

View File

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

View File

@ -116,8 +116,10 @@ class SearchResultsViewController: EnhancedTableViewController {
} }
self.currentQuery = query self.currentQuery = query
activityIndicator.isHidden = false if self.dataSource.snapshot().numberOfItems == 0 {
activityIndicator.startAnimating() activityIndicator.isHidden = false
activityIndicator.startAnimating()
}
let request = Client.search(query: query, resolve: true, limit: 10) let request = Client.search(query: query, resolve: true, limit: 10)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
@ -129,40 +131,24 @@ class SearchResultsViewController: EnhancedTableViewController {
} }
guard self.currentQuery == query else { return } guard self.currentQuery == query else { return }
let oldSnapshot = self.dataSource.snapshot()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
if self.onlySections.contains(.accounts) && !results.accounts.isEmpty {
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in snapshot.appendSections([.accounts])
oldSnapshot.itemIdentifiers(inSection: .accounts).forEach { (item) in snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
guard case let .account(id) = item else { return } self.mastodonController.cache.addAll(accounts: results.accounts)
self.mastodonController.persistentContainer.account(for: id, in: context)?.decrementReferenceCount() }
} if self.onlySections.contains(.hashtags) && !results.hashtags.isEmpty {
snapshot.appendSections([.hashtags])
oldSnapshot.itemIdentifiers(inSection: .statuses).forEach { (item) in snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
guard case let .status(id, _) = item else { return } }
self.mastodonController.persistentContainer.status(for: id, in: context)?.decrementReferenceCount() if self.onlySections.contains(.statuses) && !results.statuses.isEmpty {
} snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
if self.onlySections.contains(.accounts) && !results.accounts.isEmpty { self.mastodonController.cache.addAll(statuses: results.statuses)
snapshot.appendSections([.accounts]) self.mastodonController.cache.addAll(accounts: results.statuses.map { $0.account })
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts) }
addAccounts(results.accounts) self.dataSource.apply(snapshot)
}
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 statusCell = "statusCell"
private let accountCell = "accountCell" private let accountCell = "accountCell"
weak var mastodonController: MastodonController! let mastodonController: MastodonController
let actionType: ActionType let actionType: ActionType
let statusID: String let statusID: String
@ -58,16 +58,6 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemented") 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() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -81,26 +71,21 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude)) tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude))
if let accountIDs = accountIDs { if accountIDs == nil {
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 // account IDs haven't been set, so perform a request to load them
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.cache.status(for: statusID) else {
fatalError("Missing cached status \(statusID)") fatalError("Missing cached status \(statusID)")
} }
tableView.tableFooterView = UIActivityIndicatorView(style: .large) tableView.tableFooterView = UIActivityIndicatorView(style: .large)
let request = actionType == .favorite ? Status.getFavourites(status.id) : Status.getReblogs(status.id) let request = actionType == .favorite ? Status.getFavourites(status) : Status.getReblogs(status)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(accounts, _) = response else { fatalError() } guard case let .success(accounts, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(accounts: accounts) { self.mastodonController.cache.addAll(accounts: accounts)
DispatchQueue.main.async { DispatchQueue.main.async {
self.accountIDs = accounts.map { $0.id } self.accountIDs = accounts.map { $0.id }
self.tableView.tableFooterView = nil self.tableView.tableFooterView = nil
}
} }
} }
} }

View File

@ -37,7 +37,7 @@ class InstanceTimelineViewController: TimelineTableViewController {
self.instanceURL = url 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 // 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, transient: true) instanceMastodonController = MastodonController(instanceURL: url)
super.init(for: .public(local: true), mastodonController: instanceMastodonController) super.init(for: .public(local: true), mastodonController: instanceMastodonController)

View File

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

View File

@ -11,45 +11,32 @@ import SafariServices
class EnhancedTableViewController: UITableViewController { class EnhancedTableViewController: UITableViewController {
private var prevScrollToTopOffset: CGPoint? = nil var prevScrollToTopOffset: CGPoint? = nil
private(set) var isCurrentlyScrollingToTop = false
private var prevScrollViewContentOffset: CGPoint? private var topOffset: CGPoint {
private(set) var scrollViewDirection: CGFloat = 0 // 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!
// MARK: Scroll View Delegate 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)
}
override func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { override func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
if let offset = prevScrollToTopOffset { if let offset = prevScrollToTopOffset {
tableView.setContentOffset(offset, animated: true) tableView.setContentOffset(offset, animated: true)
prevScrollToTopOffset = nil prevScrollToTopOffset = nil
return false
} else { } else {
prevScrollToTopOffset = tableView.contentOffset prevScrollToTopOffset = tableView.contentOffset
isCurrentlyScrollingToTop = true tableView.setContentOffset(topOffset, animated: 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) { override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
prevScrollToTopOffset = nil 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) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell { if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell {
cell.didSelectCell() cell.didSelectCell()
@ -85,8 +72,8 @@ extension EnhancedTableViewController {
if let viewController = animator.previewViewController { if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop animator.preferredCommitStyle = .pop
animator.addCompletion { animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting { if viewController is LargeImageViewController || viewController is GalleryViewController || viewController is SFSafariViewController {
customPresenting.presentFromPreview(presenter: self) self.present(viewController, animated: true)
} else { } else {
self.show(viewController, sender: nil) self.show(viewController, sender: nil)
} }

View File

@ -35,12 +35,6 @@ class InteractivePushTransition: UIPercentDrivenInteractiveTransition {
interactivePushGestureRecognizer.edges = .right interactivePushGestureRecognizer.edges = .right
interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!) interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
navigationController.view.addGestureRecognizer(interactivePushGestureRecognizer) 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) { @objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) {

View File

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

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

View File

@ -150,15 +150,30 @@ extension TuskerNavigationDelegate where Self: UIViewController {
vc.presentationController?.delegate = compose vc.presentationController?.delegate = compose
present(vc, animated: true) 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 { func largeImage(_ image: UIImage, description: String?, sourceView: UIImageView) -> LargeImageViewController {
let vc = LargeImageViewController(image: image, description: description, sourceView: sourceView) let vc = LargeImageViewController(image: image, description: description, sourceInfo: sourceViewInfo(sourceView))
vc.transitioningDelegate = self vc.transitioningDelegate = self
return vc return vc
} }
func largeImage(gifData: Data, description: String?, sourceView: UIImageView) -> LargeImageViewController { func largeImage(gifData: Data, description: String?, sourceView: UIImageView) -> LargeImageViewController {
let vc = LargeImageViewController(image: UIImage(data: gifData)!, description: description, sourceView: sourceView) let vc = LargeImageViewController(image: UIImage(data: gifData)!, description: description, sourceInfo: sourceViewInfo(sourceView))
vc.transitioningDelegate = self vc.transitioningDelegate = self
vc.gifData = gifData vc.gifData = gifData
return vc return vc
@ -174,7 +189,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController { func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
let vc = LoadingLargeImageViewController(url: url, cache: cache, imageDescription: description) let vc = LoadingLargeImageViewController(url: url, cache: cache, imageDescription: description)
vc.animationSourceView = sourceView vc.animationSourceInfo = sourceViewInfo(sourceView)
vc.transitioningDelegate = self vc.transitioningDelegate = self
return vc return vc
} }
@ -184,7 +199,8 @@ extension TuskerNavigationDelegate where Self: UIViewController {
} }
func gallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) -> GalleryViewController { func gallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) -> GalleryViewController {
let vc = GalleryViewController(attachments: attachments, sourceViews: sourceViews, startIndex: startIndex) let sourcesInfo = sourceViews.map(sourceViewInfo)
let vc = GalleryViewController(attachments: attachments, sourcesInfo: sourcesInfo, startIndex: startIndex)
vc.transitioningDelegate = self vc.transitioningDelegate = self
return vc return vc
} }
@ -203,15 +219,16 @@ extension TuskerNavigationDelegate where Self: UIViewController {
} }
private func moreOptions(forStatus statusID: String) -> UIActivityViewController { private func moreOptions(forStatus statusID: String) -> UIActivityViewController {
guard let status = apiController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") } guard let status = apiController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
guard let url = status.url else { fatalError("Missing url for status \(statusID)") } guard let url = status.url else { fatalError("Missing url for status \(statusID)") }
var customActivites: [UIActivity] = [OpenInSafariActivity()] var customActivites: [UIActivity] = [OpenInSafariActivity()]
let bookmarked = status.bookmarked ?? false if let bookmarked = status.bookmarked {
customActivites.insert(bookmarked ? UnbookmarkStatusActivity() : BookmarkStatusActivity(), at: 0) customActivites.insert(bookmarked ? UnbookmarkStatusActivity() : BookmarkStatusActivity(), at: 0)
}
if apiController.account != nil, status.account.id == apiController.account.id {
let pinned = status.pinned ?? false if status.account == apiController.account,
let pinned = status.pinned {
customActivites.insert(pinned ? UnpinStatusActivity() : PinStatusActivity(), at: 1) customActivites.insert(pinned ? UnpinStatusActivity() : PinStatusActivity(), at: 1)
} }
@ -221,7 +238,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
} }
private func moreOptions(forAccount accountID: String) -> UIActivityViewController { private func moreOptions(forAccount accountID: String) -> UIActivityViewController {
guard let account = apiController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } guard let account = apiController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID)") }
return moreOptions(forURL: account.url) return moreOptions(forURL: account.url)
} }

View File

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

View File

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

View File

@ -12,8 +12,7 @@ import Gifu
import AVFoundation import AVFoundation
protocol AttachmentViewDelegate: class { protocol AttachmentViewDelegate: class {
func attachmentViewGallery(startingAt index: Int) -> UIViewController func showAttachmentsGallery(startingAt index: Int)
func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
} }
class AttachmentView: UIImageView, GIFAnimatable { class AttachmentView: UIImageView, GIFAnimatable {
@ -56,8 +55,6 @@ class AttachmentView: UIImageView, GIFAnimatable {
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imagePressed))) addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imagePressed)))
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
addInteraction(UIContextMenuInteraction(delegate: self))
} }
@objc func preferencesChanged() { @objc func preferencesChanged() {
@ -81,8 +78,6 @@ class AttachmentView: UIImageView, GIFAnimatable {
loadVideo() loadVideo()
case .audio: case .audio:
loadAudio() loadAudio()
case .gifv:
loadGifv()
default: default:
preconditionFailure("invalid attachment type") preconditionFailure("invalid attachment type")
} }
@ -113,9 +108,9 @@ class AttachmentView: UIImageView, GIFAnimatable {
let asset = AVURLAsset(url: attachmentURL) let asset = AVURLAsset(url: attachmentURL)
let generator = AVAssetImageGenerator(asset: asset) let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } guard let image = try? generator.copyCGImage(at: CMTime(seconds: 0, preferredTimescale: 1), actualTime: nil) else { return }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async {
guard let self = self, self.attachment.url == attachmentURL else { return } guard self.attachment.url == attachmentURL else { return }
self.image = UIImage(cgImage: image) self.image = UIImage(cgImage: image)
} }
} }
@ -152,69 +147,12 @@ 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) { override func display(_ layer: CALayer) {
updateImageIfNeeded() updateImageIfNeeded()
} }
func showGallery() {
if let delegate = delegate {
let gallery = delegate.attachmentViewGallery(startingAt: index)
delegate.attachmentViewPresent(gallery, animated: true)
}
}
@objc func imagePressed() { @objc func imagePressed() {
showGallery() delegate?.showAttachmentsGallery(startingAt: index)
} }
} }
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 { class AttachmentsContainerView: UIView {
static let supportedAttachmentTypes = [Attachment.Kind.image, .video, .audio, .gifv] static let supportedAttachmentTypes = [Attachment.Kind.image, .video, .audio]
weak var delegate: AttachmentViewDelegate? weak var delegate: AttachmentViewDelegate?
@ -48,7 +48,7 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface // MARK: - User Interaface
func updateUI(status: StatusMO) { func updateUI(status: Status) {
self.statusID = status.id self.statusID = status.id
attachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) } attachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) }
@ -64,20 +64,12 @@ class AttachmentsContainerView: UIView {
switch attachments.count { switch attachments.count {
case 1: case 1:
let attachmentView = createAttachmentView(index: 0) let attachmentView = createAttachmentView(index: 0)
attachmentView.layer.cornerRadius = 5
attachmentView.layer.masksToBounds = true
fillView(attachmentView) fillView(attachmentView)
sendSubviewToBack(attachmentView) sendSubviewToBack(attachmentView)
accessibilityElements.append(attachmentView) accessibilityElements.append(attachmentView)
case 2: case 2:
let left = createAttachmentView(index: 0) let left = createAttachmentView(index: 0)
left.layer.cornerRadius = 5
left.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
left.layer.masksToBounds = true
let right = createAttachmentView(index: 1) let right = createAttachmentView(index: 1)
right.layer.cornerRadius = 5
right.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
right.layer.masksToBounds = true
let stack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [ let stack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left, left,
right right
@ -91,17 +83,8 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(right) accessibilityElements.append(right)
case 3: case 3:
let left = createAttachmentView(index: 0) let left = createAttachmentView(index: 0)
left.layer.cornerRadius = 5
left.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
left.layer.masksToBounds = true
let topRight = createAttachmentView(index: 1) let topRight = createAttachmentView(index: 1)
topRight.layer.cornerRadius = 5
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let bottomRight = createAttachmentView(index: 2) let bottomRight = createAttachmentView(index: 2)
bottomRight.layer.cornerRadius = 5
bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner
bottomRight.layer.masksToBounds = true
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [ let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left, left,
createAttachmentsStack(axis: .vertical, arrangedSubviews: [ createAttachmentsStack(axis: .vertical, arrangedSubviews: [
@ -120,25 +103,13 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(bottomRight) accessibilityElements.append(bottomRight)
case 4: case 4:
let topLeft = createAttachmentView(index: 0) let topLeft = createAttachmentView(index: 0)
topLeft.layer.cornerRadius = 5
topLeft.layer.maskedCorners = .layerMinXMinYCorner
topLeft.layer.masksToBounds = true
let bottomLeft = createAttachmentView(index: 2) let bottomLeft = createAttachmentView(index: 2)
bottomLeft.layer.cornerRadius = 5
bottomLeft.layer.maskedCorners = .layerMinXMaxYCorner
bottomLeft.layer.masksToBounds = true
let left = createAttachmentsStack(axis: .vertical, arrangedSubviews: [ let left = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topLeft, topLeft,
bottomLeft bottomLeft
]) ])
let topRight = createAttachmentView(index: 1) let topRight = createAttachmentView(index: 1)
topRight.layer.cornerRadius = 5
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let bottomRight = createAttachmentView(index: 3) let bottomRight = createAttachmentView(index: 3)
bottomRight.layer.cornerRadius = 5
bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner
bottomRight.layer.masksToBounds = true
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [ let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left, left,
createAttachmentsStack(axis: .vertical, arrangedSubviews: [ createAttachmentsStack(axis: .vertical, arrangedSubviews: [
@ -164,9 +135,6 @@ class AttachmentsContainerView: UIView {
moreView.translatesAutoresizingMaskIntoConstraints = false moreView.translatesAutoresizingMaskIntoConstraints = false
moreView.isUserInteractionEnabled = true moreView.isUserInteractionEnabled = true
moreView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(moreViewTapped))) moreView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(moreViewTapped)))
moreView.layer.cornerRadius = 5
moreView.layer.maskedCorners = .layerMaxXMaxYCorner
moreView.layer.masksToBounds = true
let moreLabel = UILabel() let moreLabel = UILabel()
moreLabel.text = "\(attachments.count - 3) more..." moreLabel.text = "\(attachments.count - 3) more..."
moreLabel.textColor = .secondaryLabel moreLabel.textColor = .secondaryLabel
@ -176,21 +144,12 @@ class AttachmentsContainerView: UIView {
moreView.accessibilityLabel = moreLabel.text moreView.accessibilityLabel = moreLabel.text
let topLeft = createAttachmentView(index: 0) let topLeft = createAttachmentView(index: 0)
topLeft.layer.cornerRadius = 5
topLeft.layer.maskedCorners = .layerMinXMinYCorner
topLeft.layer.masksToBounds = true
let bottomLeft = createAttachmentView(index: 2) let bottomLeft = createAttachmentView(index: 2)
bottomLeft.layer.cornerRadius = 5
bottomLeft.layer.maskedCorners = .layerMinXMaxYCorner
bottomLeft.layer.masksToBounds = true
let left = createAttachmentsStack(axis: .vertical, arrangedSubviews: [ let left = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topLeft, topLeft,
bottomLeft bottomLeft
]) ])
let topRight = createAttachmentView(index: 1) let topRight = createAttachmentView(index: 1)
topRight.layer.cornerRadius = 5
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [ let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left, left,
createAttachmentsStack(axis: .vertical, arrangedSubviews: [ createAttachmentsStack(axis: .vertical, arrangedSubviews: [
@ -366,10 +325,7 @@ class AttachmentsContainerView: UIView {
@objc func moreViewTapped() { @objc func moreViewTapped() {
guard attachments.count > 4 else { return } 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 // the more view shows up in place of the fourth attachemtn view, show tapping it should start at the fourth attachment
if let delegate = delegate { delegate?.showAttachmentsGallery(startingAt: 3)
let gallery = delegate.attachmentViewGallery(startingAt: 3)
delegate.attachmentViewPresent(gallery, animated: true)
}
} }
} }

View File

@ -1,49 +0,0 @@
//
// 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) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
} }
func updateUI(for status: StatusMO) { func updateUI(for status: Status) {
displayNameLabel.updateForAccountDisplayName(account: status.account) displayNameLabel.updateForAccountDisplayName(account: status.account)
usernameLabel.text = "@\(status.account.acct)" usernameLabel.text = "@\(status.account.acct)"
statusContentTextView.overrideMastodonController = mastodonController statusContentTextView.overrideMastodonController = mastodonController

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,10 +52,6 @@ class ProfileHeaderTableViewCell: UITableViewCell {
maskLayer.path = CGPath(ellipseIn: moreButtonVisualEffectView.bounds, transform: nil) maskLayer.path = CGPath(ellipseIn: moreButtonVisualEffectView.bounds, transform: nil)
moreButtonVisualEffectView.layer.mask = maskLayer 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) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }
@ -63,7 +59,7 @@ class ProfileHeaderTableViewCell: UITableViewCell {
guard accountID != self.accountID else { return } guard accountID != self.accountID else { return }
self.accountID = accountID self.accountID = accountID
guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID)") }
updateUIForPreferences() updateUIForPreferences()
@ -88,50 +84,55 @@ class ProfileHeaderTableViewCell: UITableViewCell {
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis) noteTextView.setEmojis(account.emojis)
// don't show relationship label for the user's own account
if accountID != mastodonController.account.id { if accountID != mastodonController.account.id {
let request = Client.getRelationships(accounts: [accountID]) // don't show relationship label for the user's own account
mastodonController.run(request) { (response) in if let relationship = mastodonController.cache.relationship(for: accountID) {
if case let .success(results, _) = response, let relationship = results.first { followsYouLabel.isHidden = !relationship.followedBy
} else {
mastodonController.cache.relationship(for: accountID) { relationship in
DispatchQueue.main.async { DispatchQueue.main.async {
self.followsYouLabel.isHidden = !relationship.followedBy self.followsYouLabel.isHidden = !(relationship?.followedBy ?? false)
} }
} }
} }
} }
fieldsStackView.isHidden = account.fields.isEmpty if let fields = account.fields, !fields.isEmpty {
fieldsStackView.isHidden = false
fieldsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for field in account.fields { fieldsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
let nameLabel = UILabel() for field in fields {
nameLabel.text = field.name let nameLabel = UILabel()
nameLabel.font = .boldSystemFont(ofSize: 17) nameLabel.text = field.name
nameLabel.textAlignment = .right nameLabel.font = .boldSystemFont(ofSize: 17)
nameLabel.numberOfLines = 0 nameLabel.textAlignment = .right
fieldNamesStackView.addArrangedSubview(nameLabel) nameLabel.numberOfLines = 0
fieldNamesStackView.addArrangedSubview(nameLabel)
let valueTextView = ContentTextView()
valueTextView.isSelectable = false let valueTextView = ContentTextView()
valueTextView.font = .systemFont(ofSize: 17) valueTextView.isSelectable = false
valueTextView.setTextFromHtml(field.value) valueTextView.font = .systemFont(ofSize: 17)
valueTextView.setEmojis(account.emojis) valueTextView.setTextFromHtml(field.value)
valueTextView.textAlignment = .left valueTextView.setEmojis(account.emojis)
valueTextView.awakeFromNib() valueTextView.textAlignment = .left
valueTextView.navigationDelegate = delegate valueTextView.awakeFromNib()
fieldValuesStack.addArrangedSubview(valueTextView) valueTextView.navigationDelegate = delegate
fieldValuesStack.addArrangedSubview(valueTextView)
}
} else {
fieldsStackView.isHidden = true
} }
if accountUpdater == nil { if accountUpdater == nil {
accountUpdater = mastodonController.persistentContainer.accountSubject accountUpdater = mastodonController.cache.accountSubject
.filter { [unowned self] in $0 == self.accountID } .filter { [unowned self] in $0.id == self.accountID }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [unowned self] in self.updateUI(for: $0) } .sink { [unowned self] in self.updateUI(for: $0.id) }
} }
} }
@objc func updateUIForPreferences() { @objc func updateUIForPreferences() {
guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") } guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
@ -148,22 +149,13 @@ class ProfileHeaderTableViewCell: UITableViewCell {
} }
@objc func avatarPressed() { @objc func avatarPressed() {
guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") } guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
delegate?.showLoadingLargeImage(url: account.avatar, cache: .avatars, description: nil, animatingFrom: avatarImageView) delegate?.showLoadingLargeImage(url: account.avatar, cache: .avatars, description: nil, animatingFrom: avatarImageView)
} }
@objc func headerPressed() { @objc func headerPressed() {
guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") } guard let account = mastodonController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
delegate?.showLoadingLargeImage(url: account.header, cache: .headers, description: nil, animatingFrom: headerImageView) delegate?.showLoadingLargeImage(url: account.header, cache: .headers, description: nil, animatingFrom: headerImageView)
} }
} }
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,6 +69,11 @@ class BaseStatusTableViewCell: UITableViewCell {
private var statusUpdater: Cancellable? private var statusUpdater: Cancellable?
private var accountUpdater: Cancellable? private var accountUpdater: Cancellable?
deinit {
statusUpdater?.cancel()
accountUpdater?.cancel()
}
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -79,6 +84,8 @@ class BaseStatusTableViewCell: UITableViewCell {
avatarImageView.layer.masksToBounds = true avatarImageView.layer.masksToBounds = true
attachmentsView.delegate = self attachmentsView.delegate = self
attachmentsView.layer.cornerRadius = 5
attachmentsView.layer.masksToBounds = true
collapseButton.layer.masksToBounds = true collapseButton.layer.masksToBounds = true
collapseButton.layer.cornerRadius = 5 collapseButton.layer.cornerRadius = 5
@ -91,32 +98,24 @@ class BaseStatusTableViewCell: UITableViewCell {
open func createObserversIfNecessary() { open func createObserversIfNecessary() {
if statusUpdater == nil { if statusUpdater == nil {
statusUpdater = mastodonController.persistentContainer.statusSubject statusUpdater = mastodonController.cache.statusSubject
.filter { [unowned self] in $0 == self.statusID } .filter { [unowned self] in $0.id == self.statusID }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [unowned self] in .sink { [unowned self] in self.updateStatusState(status: $0) }
if let status = self.mastodonController.persistentContainer.status(for: $0) {
self.updateStatusState(status: status)
}
}
} }
if accountUpdater == nil { if accountUpdater == nil {
accountUpdater = mastodonController.persistentContainer.accountSubject accountUpdater = mastodonController.cache.accountSubject
.filter { [unowned self] in $0 == self.accountID } .filter { [unowned self] in $0.id == self.accountID }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [unowned self] in .sink { [unowned self] in self.updateUI(account: $0) }
if let account = self.mastodonController.persistentContainer.account(for: $0) {
self.updateUI(account: account)
}
}
} }
} }
func updateUI(statusID: String, state: StatusState) { func updateUI(statusID: String, state: StatusState) {
createObserversIfNecessary() createObserversIfNecessary()
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.cache.status(for: statusID) else {
fatalError("Missing cached status") fatalError("Missing cached status")
} }
self.statusID = statusID self.statusID = statusID
@ -164,9 +163,9 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
} }
func updateStatusState(status: StatusMO) { func updateStatusState(status: Status) {
favorited = status.favourited favorited = status.favourited ?? false
reblogged = status.reblogged reblogged = status.reblogged ?? false
if favorited { if favorited {
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label") favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
@ -180,22 +179,22 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
} }
func updateUI(account: AccountMO) { func updateUI(account: Account) {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
avatarImageView.image = nil avatarImageView.image = nil
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == account.id else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
guard let self = self, let data = data, self.accountID == account.id else { return }
self.avatarImageView.image = UIImage(data: data) self.avatarImageView.image = UIImage(data: data)
} }
} }
} }
@objc func updateUIForPreferences() { @objc func updateUIForPreferences() {
guard let mastodonController = mastodonController, let account = mastodonController.persistentContainer.account(for: accountID) else { return } guard let mastodonController = mastodonController, let account = mastodonController.cache.account(for: accountID) else { return }
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false) attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.cache.status(for: statusID)?.sensitive ?? false)
} }
override func prepareForReuse() { override func prepareForReuse() {
@ -251,18 +250,18 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
@IBAction func favoritePressed() { @IBAction func favoritePressed() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } guard let status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let oldValue = favorited let oldValue = favorited
favorited = !favorited favorited = !favorited
let realStatus = status.reblog ?? status let realStatus: Status = status.reblog ?? status
let request = (favorited ? Status.favourite : Status.unfavourite)(realStatus.id) let request = (favorited ? Status.favourite : Status.unfavourite)(realStatus)
mastodonController.run(request) { response in mastodonController.run(request) { response in
DispatchQueue.main.async { DispatchQueue.main.async {
if case let .success(newStatus, _) = response { if case let .success(newStatus, _) = response {
self.favorited = newStatus.favourited ?? false self.favorited = newStatus.favourited ?? false
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false) self.mastodonController.cache.add(status: newStatus)
UIImpactFeedbackGenerator(style: .light).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else { } else {
self.favorited = oldValue self.favorited = oldValue
@ -276,18 +275,18 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
@IBAction func reblogPressed() { @IBAction func reblogPressed() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } guard let status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let oldValue = reblogged let oldValue = reblogged
reblogged = !reblogged reblogged = !reblogged
let realStatus = status.reblog ?? status let realStatus: Status = status.reblog ?? status
let request = (reblogged ? Status.reblog : Status.unreblog)(realStatus.id) let request = (reblogged ? Status.reblog : Status.unreblog)(realStatus)
mastodonController.run(request) { response in mastodonController.run(request) { response in
DispatchQueue.main.async { DispatchQueue.main.async {
if case let .success(newStatus, _) = response { if case let .success(newStatus, _) = response {
self.reblogged = newStatus.reblogged ?? false self.reblogged = newStatus.reblogged ?? false
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false) self.mastodonController.cache.add(status: newStatus)
UIImpactFeedbackGenerator(style: .light).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else { } else {
self.reblogged = oldValue self.reblogged = oldValue
@ -313,14 +312,10 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
extension BaseStatusTableViewCell: AttachmentViewDelegate { extension BaseStatusTableViewCell: AttachmentViewDelegate {
func attachmentViewGallery(startingAt index: Int) -> UIViewController { func showAttachmentsGallery(startingAt index: Int) {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } guard let status = mastodonController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:)) let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
return delegate!.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index) delegate?.showGallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
}
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
delegate?.show(vc)
} }
} }
@ -334,6 +329,19 @@ extension BaseStatusTableViewCell: MenuPreviewProvider {
content: { ProfileTableViewController(accountID: self.accountID, mastodonController: mastodonController) }, content: { ProfileTableViewController(accountID: self.accountID, mastodonController: mastodonController) },
actions: { self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) } 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) return self.getStatusCellPreviewProviders(for: location, sourceViewController: sourceViewController)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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