Account statuses wip

This commit is contained in:
Justin Mazzocchi 2020-09-21 23:53:11 -07:00
parent 92bc777a7e
commit 55ba5f856a
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
16 changed files with 236 additions and 39 deletions

View file

@ -114,6 +114,16 @@ public extension ContentDatabase {
.eraseToAnyPublisher()
}
func insert(accounts: [Account]) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher {
for account in accounts {
try account.save($0)
}
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func setLists(_ lists: [MastodonList]) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher {
for list in lists {
@ -246,6 +256,20 @@ public extension ContentDatabase {
.publisher(in: databaseQueue)
.eraseToAnyPublisher()
}
func accountObservation(id: String) -> AnyPublisher<Account?, Error> {
ValueObservation.tracking(AccountRecord.filter(Column("id") == id).accountResultRequest.fetchOne)
.removeDuplicates()
.publisher(in: databaseQueue)
.map {
if let result = $0 {
return Account(result: result)
} else {
return nil
}
}
.eraseToAnyPublisher()
}
}
private extension ContentDatabase {

View file

@ -2,7 +2,7 @@
import Foundation
public enum AccountStatusCollection: String, Codable {
public enum AccountStatusCollection: String, Codable, CaseIterable {
case statuses
case statusesAndReplies
case media

View file

@ -0,0 +1,17 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import ViewModels
extension AccountStatusCollection {
var title: String {
switch self {
case .statuses:
return NSLocalizedString("account.statuses", comment: "")
case .statusesAndReplies:
return NSLocalizedString("account.statuses-and-replies", comment: "")
case .media:
return NSLocalizedString("account.media", comment: "")
}
}
}

View file

@ -1,5 +1,8 @@
// Copyright © 2020 Metabolist. All rights reserved.
"account.statuses" = "Posts";
"account.statuses-and-replies" = "Posts & Replies";
"account.media" = "Media";
"add" = "Add";
"apns-default-message" = "New notification";
"add-identity.instance-url" = "Instance URL";

View file

@ -6,6 +6,7 @@ import Mastodon
public enum AccountEndpoint {
case verifyCredentials
case accounts(id: String)
}
extension AccountEndpoint: Endpoint {
@ -18,12 +19,13 @@ extension AccountEndpoint: Endpoint {
public var pathComponentsInContext: [String] {
switch self {
case .verifyCredentials: return ["verify_credentials"]
case let .accounts(id): return [id]
}
}
public var method: HTTPMethod {
switch self {
case .verifyCredentials: return .get
case .verifyCredentials, .accounts: return .get
}
}
}

View file

@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0030981250C6C8500EACB32 /* URL+Extensions.swift */; };
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01EF22325182B1F00650C6B /* AccountHeaderView.swift */; };
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; };
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; };
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; };
@ -16,6 +17,7 @@
D0625E5F250F0CFF00502611 /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5E250F0CFF00502611 /* StatusView.swift */; };
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; };
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
D0B5FE9B251583DB00478838 /* AccountStatusCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* AccountStatusCollection+Extensions.swift */; };
D0B7434925100DBB00C13DB6 /* StatusView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D0B7434825100DBB00C13DB6 /* StatusView.xib */; };
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
@ -84,6 +86,7 @@
/* Begin PBXFileReference section */
D0030981250C6C8500EACB32 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
D01EF22325182B1F00650C6B /* AccountHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountHeaderView.swift; sourceTree = "<group>"; };
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = "<group>"; };
D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = "<group>"; };
D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; };
@ -96,6 +99,7 @@
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
D0B5FE9A251583DB00478838 /* AccountStatusCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountStatusCollection+Extensions.swift"; sourceTree = "<group>"; };
D0B7434825100DBB00C13DB6 /* StatusView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StatusView.xib; sourceTree = "<group>"; };
D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = "<group>"; };
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
@ -222,7 +226,6 @@
D0625E58250F092900502611 /* StatusListCell.swift */,
D0625E5E250F0CFF00502611 /* StatusView.swift */,
D0B7434825100DBB00C13DB6 /* StatusView.xib */,
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */,
);
path = Status;
sourceTree = "<group>";
@ -254,7 +257,7 @@
D0C7D42024F76169001EBDBB /* Views */ = {
isa = PBXGroup;
children = (
D0625E55250F086B00502611 /* Status */,
D01EF22325182B1F00650C6B /* AccountHeaderView.swift */,
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
D01F41E024F8885900D55A2D /* Attachments */,
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
@ -270,8 +273,10 @@
D0C7D42724F76169001EBDBB /* RootView.swift */,
D02E1F94250B13210071AD56 /* SafariView.swift */,
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
D0625E55250F086B00502611 /* Status */,
D0C7D42524F76169001EBDBB /* StatusListView.swift */,
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */,
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -305,12 +310,13 @@
D0C7D46824F76169001EBDBB /* Extensions */ = {
isa = PBXGroup;
children = (
D0B5FE9A251583DB00478838 /* AccountStatusCollection+Extensions.swift */,
D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */,
D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */,
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */,
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */,
D0030981250C6C8500EACB32 /* URL+Extensions.swift */,
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -506,6 +512,7 @@
D0625E5F250F0CFF00502611 /* StatusView.swift in Sources */,
D0625E59250F092900502611 /* StatusListCell.swift in Sources */,
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
D0B5FE9B251583DB00478838 /* AccountStatusCollection+Extensions.swift in Sources */,
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */,
D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
@ -514,6 +521,7 @@
D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */,
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */,
D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */,

View file

@ -19,7 +19,12 @@ public struct AccountStatusesService {
}
public extension AccountStatusesService {
func statusListService(collectionPublisher: AnyPublisher<AccountStatusCollection, Never>) -> StatusListService {
func accountObservation() -> AnyPublisher<Account?, Error> {
contentDatabase.accountObservation(id: accountID)
}
func statusListService(
collectionPublisher: CurrentValueSubject<AccountStatusCollection, Never>) -> StatusListService {
StatusListService(
accountID: accountID,
collection: collectionPublisher,
@ -37,4 +42,10 @@ public extension AccountStatusesService {
.flatMap { contentDatabase.insert(pinnedStatuses: $0, accountID: accountID) }
.eraseToAnyPublisher()
}
func fetchAccount() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(AccountEndpoint.accounts(id: accountID))
.flatMap { contentDatabase.insert(accounts: [$0]) }
.eraseToAnyPublisher()
}
}

View file

@ -50,7 +50,7 @@ extension StatusListService {
init(
accountID: String,
collection: AnyPublisher<AccountStatusCollection, Never>,
collection: CurrentValueSubject<AccountStatusCollection, Never>,
mastodonAPIClient: MastodonAPIClient,
contentDatabase: ContentDatabase) {
self.init(
@ -63,33 +63,29 @@ extension StatusListService {
filterContext: .account,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { maxID, minID in
Just((maxID, minID)).combineLatest(collection).flatMap { params -> AnyPublisher<Never, Error> in
let ((maxID, minID), collection) = params
let excludeReplies: Bool
let onlyMedia: Bool
let excludeReplies: Bool
let onlyMedia: Bool
switch collection {
case .statuses:
excludeReplies = true
onlyMedia = false
case .statusesAndReplies:
excludeReplies = false
onlyMedia = false
case .media:
excludeReplies = true
onlyMedia = true
}
let endpoint = StatusesEndpoint.accountsStatuses(
id: accountID,
excludeReplies: excludeReplies,
onlyMedia: onlyMedia,
pinned: false)
return mastodonAPIClient.request(Paged(endpoint, maxID: maxID, minID: minID))
.flatMap { contentDatabase.insert(statuses: $0, accountID: accountID, collection: collection) }
.eraseToAnyPublisher()
switch collection.value {
case .statuses:
excludeReplies = true
onlyMedia = false
case .statusesAndReplies:
excludeReplies = false
onlyMedia = false
case .media:
excludeReplies = true
onlyMedia = true
}
.eraseToAnyPublisher()
let endpoint = StatusesEndpoint.accountsStatuses(
id: accountID,
excludeReplies: excludeReplies,
onlyMedia: onlyMedia,
pinned: false)
return mastodonAPIClient.request(Paged(endpoint, maxID: maxID, minID: minID))
.flatMap { contentDatabase.insert(statuses: $0, accountID: accountID, collection: collection.value) }
.eraseToAnyPublisher()
}
}
}

View file

@ -5,7 +5,7 @@ import SafariServices
import SwiftUI
import ViewModels
final class StatusListViewController: UITableViewController {
class StatusListViewController: UITableViewController {
private let viewModel: StatusListViewModel
private let loadingTableFooterView = LoadingTableFooterView()
private var cancellables = Set<AnyCancellable>()
@ -77,6 +77,21 @@ final class StatusListViewController: UITableViewController {
self.sizeTableHeaderFooterViews()
}
.store(in: &cancellables)
if let accountsStatusesViewModel = viewModel as? AccountStatusesViewModel {
// Initial size is to avoid unsatisfiable constraint warning
let accountHeaderView = AccountHeaderView(
frame: .init(
origin: .zero,
size: .init(width: 100, height: 100)))
accountHeaderView.viewModel = accountsStatusesViewModel
accountsStatusesViewModel.$account.dropFirst().receive(on: DispatchQueue.main).sink { [weak self] _ in
accountHeaderView.viewModel = accountsStatusesViewModel
self?.sizeTableHeaderFooterViews()
}
.store(in: &cancellables)
tableView.tableHeaderView = accountHeaderView
}
}
override func viewWillAppear(_ animated: Bool) {

View file

@ -6,20 +6,25 @@ import Mastodon
import ServiceLayer
public class AccountStatusesViewModel: StatusListViewModel {
@Published var collection: AccountStatusCollection
@Published public private(set) var account: Account?
@Published public var collection = AccountStatusCollection.statuses
private let accountStatusesService: AccountStatusesService
private var cancellables = Set<AnyCancellable>()
init(accountStatusesService: AccountStatusesService) {
self.accountStatusesService = accountStatusesService
var collection = Published(initialValue: AccountStatusCollection.statuses)
_collection = collection
let collectionSubject = CurrentValueSubject<AccountStatusCollection, Never>(.statuses)
super.init(
statusListService: accountStatusesService.statusListService(
collectionPublisher: collection.projectedValue.eraseToAnyPublisher()))
collectionPublisher: collectionSubject))
$collection.sink(receiveValue: collectionSubject.send).store(in: &cancellables)
accountStatusesService.accountObservation()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$account)
}
public override func request(maxID: String? = nil, minID: String? = nil) {
@ -37,3 +42,12 @@ public class AccountStatusesViewModel: StatusListViewModel {
collection == .statuses && statusIDs.first?.contains(status.id) ?? false
}
}
public extension AccountStatusesViewModel {
func fetchAccount() {
accountStatusesService.fetchAccount()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
}

View file

@ -0,0 +1,5 @@
// Copyright © 2020 Metabolist. All rights reserved.
import ServiceLayer
public typealias AccountStatusCollection = ServiceLayer.AccountStatusCollection

View file

@ -28,7 +28,7 @@ public class StatusListViewModel: ObservableObject {
.handleEvents(receiveOutput: { [weak self] in
self?.determineIfScrollPositionShouldBeMaintained(newStatusSections: $0)
self?.cleanViewModelCache(newStatusSections: $0)
self?.statuses = Dictionary(uniqueKeysWithValues: $0.reduce([], +).map { ($0.id, $0) })
self?.statuses = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) })
})
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self)

View file

@ -123,6 +123,13 @@ public extension StatusViewModel {
.eraseToAnyPublisher())
}
func accountSelected() {
eventsSubject.send(
Just(Event.navigation(.accountID(statusService.status.displayStatus.account.id)))
.setFailureType(to: Error.self)
.eraseToAnyPublisher())
}
func toggleFavorited() {
eventsSubject.send(statusService.toggleFavorited().map { _ in Event.ignorableOutput }.eraseToAnyPublisher())
}

View file

@ -0,0 +1,91 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Kingfisher
import UIKit
import ViewModels
class AccountHeaderView: UIView {
let headerImageView = UIImageView()
let noteTextView = TouchFallthroughTextView()
let segmentedControl = UISegmentedControl()
var viewModel: AccountStatusesViewModel? {
didSet {
if let account = viewModel?.account {
headerImageView.kf.setImage(with: account.header)
let noteFont = UIFont.preferredFont(forTextStyle: .callout)
let mutableNote = NSMutableAttributedString(attributedString: account.note.attributed)
let noteRange = NSRange(location: 0, length: mutableNote.length)
mutableNote.removeAttribute(.font, range: noteRange)
mutableNote.addAttributes(
[.font: noteFont as Any,
.foregroundColor: UIColor.label],
range: noteRange)
mutableNote.insert(emoji: account.emojis, view: noteTextView)
mutableNote.resizeAttachments(toLineHeight: noteFont.lineHeight)
noteTextView.attributedText = mutableNote
noteTextView.isHidden = false
} else {
noteTextView.isHidden = true
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
initializationActions()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension AccountHeaderView {
func initializationActions() {
let baseStackView = UIStackView()
addSubview(headerImageView)
addSubview(baseStackView)
headerImageView.translatesAutoresizingMaskIntoConstraints = false
baseStackView.translatesAutoresizingMaskIntoConstraints = false
baseStackView.axis = .vertical
noteTextView.isScrollEnabled = false
baseStackView.addArrangedSubview(noteTextView)
for (index, collection) in AccountStatusCollection.allCases.enumerated() {
segmentedControl.insertSegment(
action: UIAction(title: collection.title) { [weak self] _ in
self?.viewModel?.collection = collection
self?.viewModel?.request()
},
at: index,
animated: false)
}
segmentedControl.selectedSegmentIndex = 0
baseStackView.addArrangedSubview(segmentedControl)
let headerImageAspectRatioConstraint = headerImageView.heightAnchor.constraint(
equalTo: headerImageView.widthAnchor,
multiplier: 9 / 16)
headerImageAspectRatioConstraint.priority = .init(999)
NSLayoutConstraint.activate([
headerImageAspectRatioConstraint,
headerImageView.topAnchor.constraint(equalTo: topAnchor),
headerImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
headerImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
baseStackView.topAnchor.constraint(equalTo: headerImageView.bottomAnchor),
baseStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
baseStackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}

View file

@ -145,6 +145,10 @@ private extension StatusView {
avatarButton.setBackgroundImage(highlightedButtonBackgroundImage, for: .highlighted)
contextParentAvatarButton.setBackgroundImage(highlightedButtonBackgroundImage, for: .highlighted)
let accountAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.accountSelected() }
avatarButton.addAction(accountAction, for: .touchUpInside)
let favoriteAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.toggleFavorited() }
favoriteButton.addAction(favoriteAction, for: .touchUpInside)