Post composition wip

This commit is contained in:
Justin Mazzocchi 2020-12-09 18:44:06 -08:00
parent fc1104c951
commit 68dc3ffa3f
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
26 changed files with 512 additions and 98 deletions

View file

@ -0,0 +1,9 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
struct CompositionRecord: Codable, FetchableRecord, PersistableRecord {
let id: Composition.Id
let text: String
}

View file

@ -0,0 +1,24 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
public class Composition {
public let id: Id
public var text: String
public init(id: Id, text: String) {
self.id = id
self.text = text
}
}
public extension Composition {
typealias Id = UUID
}
extension Composition {
convenience init(record: CompositionRecord) {
self.init(id: record.id, text: record.text)
}
}

View file

@ -182,6 +182,17 @@ public extension IdentityDatabase {
.eraseToAnyPublisher()
}
func authenticatedIdentitiesPublisher() -> AnyPublisher<[Identity], Error> {
ValueObservation.tracking(
IdentityInfo.request(IdentityRecord.order(IdentityRecord.Columns.lastUsedAt.desc))
.filter(IdentityRecord.Columns.authenticated == true && IdentityRecord.Columns.pending == false)
.fetchAll)
.removeDuplicates()
.publisher(in: databaseWriter)
.map { $0.map(Identity.init(info:)) }
.eraseToAnyPublisher()
}
func immediateMostRecentlyUsedIdentityIdPublisher() -> AnyPublisher<Identity.Id?, Error> {
ValueObservation.tracking(
IdentityRecord.select(IdentityRecord.Columns.id)
@ -199,6 +210,17 @@ public extension IdentityDatabase {
.map { $0.map(Identity.init(info:)) }
.eraseToAnyPublisher()
}
func mostRecentAuthenticatedIdentity() throws -> Identity? {
guard let info = try databaseWriter.read(
IdentityInfo.request(IdentityRecord.order(IdentityRecord.Columns.lastUsedAt.desc))
.filter(IdentityRecord.Columns.authenticated == true
&& IdentityRecord.Columns.pending == false)
.fetchOne)
else { return nil }
return Identity(info: info)
}
}
private extension IdentityDatabase {

View file

@ -0,0 +1,19 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
final class NewStatusDataSource: UICollectionViewDiffableDataSource<Int, Composition.Id> {
init(collectionView: UICollectionView, viewModelProvider: @escaping (IndexPath) -> CompositionViewModel) {
let registration = UICollectionView.CellRegistration<CompositionListCell, CompositionViewModel> {
$0.viewModel = $2
}
super.init(collectionView: collectionView) { collectionView, indexPath, _ in
collectionView.dequeueConfiguredReusableCell(
using: registration,
for: indexPath,
item: viewModelProvider(indexPath))
}
}
}

View file

@ -49,7 +49,15 @@
D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E5291257C53B600FA2C5F /* NewStatusViewController.swift */; };
D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */; };
D08E52CC257C80E300FA2C5F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0C7D45724F76169001EBDBB /* Localizable.strings */; };
D08E52D2257C811200FA2C5F /* ShareExtensionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52D1257C811200FA2C5F /* ShareExtensionError.swift */; };
D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */; };
D08E52DC257D742B00FA2C5F /* CompositionListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */; };
D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */; };
D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */; };
D08E52E4257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */; };
D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; };
D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; };
D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; };
D08E52FD257D78CB00FA2C5F /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */; };
D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; };
D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; };
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
@ -97,6 +105,11 @@
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; };
D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* TableViewController.swift */; };
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */; };
D0F2D4D1257EE84400986197 /* NewStatusDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */; };
D0F2D4D6257EED6100986197 /* NewStatusDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */; };
D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C6FAB252024BD003D0300 /* Array+Extensions.swift */; };
D0F2D54025818C4B00986197 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = D0F2D53F25818C4B00986197 /* Kingfisher */; };
D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */; };
D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FE1C8E253686F9003EF1EB /* PlayerView.swift */; };
D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */; };
/* End PBXBuildFile section */
@ -185,7 +198,10 @@
D08E529B257C58D600FA2C5F /* NewStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusView.swift; sourceTree = "<group>"; };
D08E52A5257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareExtensionNavigationViewController.swift; sourceTree = "<group>"; };
D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareErrorViewController.swift; sourceTree = "<group>"; };
D08E52D1257C811200FA2C5F /* ShareExtensionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareExtensionError.swift; sourceTree = "<group>"; };
D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShareExtensionError+Extensions.swift"; sourceTree = "<group>"; };
D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionListCell.swift; sourceTree = "<group>"; };
D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionContentConfiguration.swift; sourceTree = "<group>"; };
D08E52ED257D757100FA2C5F /* CompositionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionView.swift; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
@ -242,6 +258,7 @@
D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = "<group>"; };
D0F0B12D251A97E400942152 /* TableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewController.swift; sourceTree = "<group>"; };
D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItem+Extensions.swift"; sourceTree = "<group>"; };
D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusDataSource.swift; sourceTree = "<group>"; };
D0FE1C8E253686F9003EF1EB /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = "<group>"; };
D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCache.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -268,6 +285,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D0F2D54025818C4B00986197 /* Kingfisher in Frameworks */,
D08E52B8257C62D500FA2C5F /* ViewModels in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -373,7 +391,7 @@
D08E5273257C36CA00FA2C5F /* Info.plist */,
D08E5277257C36CB00FA2C5F /* Share Extension.entitlements */,
D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */,
D08E52D1257C811200FA2C5F /* ShareExtensionError.swift */,
D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */,
D08E52A5257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift */,
);
path = "Share Extension";
@ -383,6 +401,7 @@
isa = PBXGroup;
children = (
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */,
D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */,
);
path = "Data Sources";
sourceTree = "<group>";
@ -405,6 +424,9 @@
D0F0B125251A90F400942152 /* AccountListCell.swift */,
D0F0B10D251A868200942152 /* AccountView.swift */,
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */,
D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */,
D08E52ED257D757100FA2C5F /* CompositionView.swift */,
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */,
D00702282555E51200F38136 /* ConversationListCell.swift */,
@ -577,6 +599,7 @@
name = "Share Extension";
packageProductDependencies = (
D08E52B7257C62D500FA2C5F /* ViewModels */,
D0F2D53F25818C4B00986197 /* Kingfisher */,
);
productName = "Share Extension";
productReference = D08E526C257C36CA00FA2C5F /* Share Extension.appex */;
@ -715,6 +738,7 @@
files = (
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
D0F2D4D1257EE84400986197 /* NewStatusDataSource.swift in Sources */,
D00702292555E51200F38136 /* ConversationListCell.swift in Sources */,
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */,
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
@ -722,8 +746,10 @@
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
D08B8D612540DE3B00B1EBEF /* ZoomDismissalInteractionController.swift in Sources */,
D036AA07254B6118009094DF /* NotificationView.swift in Sources */,
D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */,
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */,
D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */,
D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */,
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */,
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
@ -743,6 +769,7 @@
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */,
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */,
D08E52DC257D742B00FA2C5F /* CompositionListCell.swift in Sources */,
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */,
D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */,
D08E5292257C53B600FA2C5F /* NewStatusViewController.swift in Sources */,
@ -804,8 +831,16 @@
files = (
D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */,
D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */,
D08E52D2257C811200FA2C5F /* ShareExtensionError.swift in Sources */,
D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */,
D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */,
D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */,
D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */,
D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */,
D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */,
D0F2D4D6257EED6100986197 /* NewStatusDataSource.swift in Sources */,
D08E52FD257D78CB00FA2C5F /* UIColor+Extensions.swift in Sources */,
D08E52E4257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1237,6 +1272,11 @@
isa = XCSwiftPackageProductDependency;
productName = ViewModels;
};
D0F2D53F25818C4B00986197 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = D06B492124D4611300642749 /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = D047FA8024C3E21000AF17C5 /* Project object */;

View file

@ -8,13 +8,14 @@ import Mastodon
import UserNotifications
public struct AppEnvironment {
public let uuid: () -> UUID
let session: URLSession
let webAuthSessionType: WebAuthSession.Type
let keychain: Keychain.Type
let userDefaults: UserDefaults
let userNotificationClient: UserNotificationClient
let reduceMotion: () -> Bool
let uuid: () -> UUID
let inMemoryContent: Bool
let fixtureDatabase: IdentityDatabase?

View file

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

View file

@ -39,6 +39,14 @@ public extension AllIdentitiesService {
database.immediateMostRecentlyUsedIdentityIdPublisher()
}
func authenticatedIdentitiesPublisher() -> AnyPublisher<[Identity], Error> {
database.authenticatedIdentitiesPublisher()
}
func mostRecentAuthenticatedIdentity() throws -> Identity? {
try database.mostRecentAuthenticatedIdentity()
}
func createIdentity(url: URL, kind: IdentityCreation) -> AnyPublisher<Never, Error> {
let id = environment.uuid()
let secrets = Secrets(identityId: id, keychain: environment.keychain)

View file

@ -228,10 +228,6 @@ public extension IdentityService {
func domainBlocksService() -> DomainBlocksService {
DomainBlocksService(mastodonAPIClient: mastodonAPIClient)
}
func newStatusService() -> NewStatusService {
NewStatusService(id: id, identityDatabase: identityDatabase, environment: environment)
}
}
private extension IdentityService {

View file

@ -1,35 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
import Secrets
public struct NewStatusService {
private var id: Identity.Id
private let identityDatabase: IdentityDatabase
private let environment: AppEnvironment
public init(id: Identity.Id, identityDatabase: IdentityDatabase, environment: AppEnvironment) {
self.id = id
self.identityDatabase = identityDatabase
self.environment = environment
}
}
extension NewStatusService {
func mastodonAPIClient() throws -> MastodonAPIClient {
let secrets = Secrets(
identityId: id,
keychain: environment.keychain)
let mastodonAPIClient = MastodonAPIClient(
session: environment.session,
instanceURL: try secrets.getInstanceURL())
mastodonAPIClient.accessToken = try secrets.getAccessToken()
return mastodonAPIClient
}
}

View file

@ -1,13 +1,10 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
enum ShareExtensionError: Error {
case noAccountFound
}
import ViewModels
extension ShareExtensionError: LocalizedError {
var errorDescription: String? {
public var errorDescription: String? {
switch self {
case .noAccountFound:
return NSLocalizedString("share-extension-error.no-account-found", comment: "")

View file

@ -7,20 +7,26 @@ import ViewModels
@objc(ShareExtensionNavigationViewController)
class ShareExtensionNavigationViewController: UINavigationController {
private let viewModel = ShareExtensionNavigationViewModel(
environment: .live(
userNotificationCenter: .current(),
reduceMotion: { UIAccessibility.isReduceMotionEnabled }))
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
let viewModel: NewStatusViewModel
let newStatusViewModel: NewStatusViewModel
do {
viewModel = try newStatusViewModel()
newStatusViewModel = try viewModel.newStatusViewModel()
} catch {
setViewControllers([ShareErrorViewController(error: error)], animated: false)
return
}
setViewControllers([NewStatusViewController(viewModel: viewModel)], animated: false)
setViewControllers(
[NewStatusViewController(viewModel: newStatusViewModel, isShareExtension: true)],
animated: false)
}
@available(*, unavailable)
@ -28,23 +34,3 @@ class ShareExtensionNavigationViewController: UINavigationController {
fatalError("init(coder:) has not been implemented")
}
}
private extension ShareExtensionNavigationViewController {
func newStatusViewModel() throws -> NewStatusViewModel {
let environment = AppEnvironment.live(
userNotificationCenter: .current(),
reduceMotion: { UIAccessibility.isReduceMotionEnabled })
let allIdentitiesService = try AllIdentitiesService(environment: environment)
var recentId: Identity.Id?
_ = allIdentitiesService.immediateMostRecentlyUsedIdentityIdPublisher()
.sink { _ in } receiveValue: { recentId = $0 }
guard let id = recentId else { throw ShareExtensionError.noAccountFound }
let newStatusService = try allIdentitiesService.identityService(id: id).newStatusService()
return NewStatusViewModel(service: newStatusService)
}
}

View file

@ -1,15 +1,35 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Kingfisher
import UIKit
import ViewModels
class NewStatusViewController: UIViewController {
class NewStatusViewController: UICollectionViewController {
private let viewModel: NewStatusViewModel
private let isShareExtension: Bool
private var cancellables = Set<AnyCancellable>()
init(viewModel: NewStatusViewModel) {
private lazy var dataSource: NewStatusDataSource = {
.init(collectionView: collectionView, viewModelProvider: viewModel.viewModel(indexPath:))
}()
init(viewModel: NewStatusViewModel, isShareExtension: Bool) {
self.viewModel = viewModel
self.isShareExtension = isShareExtension
super.init(nibName: nil, bundle: nil)
let configuration = UICollectionLayoutListConfiguration(appearance: .plain)
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
super.init(collectionViewLayout: layout)
viewModel.$identification
.sink { [weak self] in
guard let self = self else { return }
self.setupBarButtonItems(identification: $0)
}
.store(in: &cancellables)
}
@available(*, unavailable)
@ -20,18 +40,103 @@ class NewStatusViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
collectionView.dataSource = dataSource
view.backgroundColor = .systemBackground
navigationItem.leftBarButtonItem = .init(
systemItem: .close,
primaryAction: UIAction { [weak self] _ in self?.extensionContext?.completeRequest(returningItems: nil) })
setupBarButtonItems(identification: viewModel.identification)
viewModel.$compositionViewModels.sink { [weak self] in
self?.dataSource.apply([$0.map(\.composition.id)].snapshot()) {
DispatchQueue.main.async {
if let collectionView = self?.collectionView,
collectionView.indexPathsForSelectedItems?.isEmpty ?? false {
collectionView.selectItem(
at: collectionView.indexPathsForVisibleItems.first,
animated: false,
scrollPosition: .top)
}
}
}
}
.store(in: &cancellables)
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
parent?.navigationItem.leftBarButtonItem = .init(
setupBarButtonItems(identification: viewModel.identification)
}
override func collectionView(_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath) {
((cell as? CompositionListCell)?.contentView as? CompositionView)?.textView.delegate = self
}
func setupBarButtonItems(identification: Identification) {
let target = isShareExtension ? self : parent
let closeButton = UIBarButtonItem(
systemItem: .close,
primaryAction: UIAction { [weak self] _ in self?.presentingViewController?.dismiss(animated: true) })
primaryAction: UIAction { [weak self] _ in self?.dismiss() })
target?.navigationItem.leftBarButtonItem = closeButton
target?.navigationItem.titleView = viewModel.canChangeIdentity
? changeIdentityButton(identification: identification)
: nil
}
func dismiss() {
if isShareExtension {
extensionContext?.completeRequest(returningItems: nil)
} else {
presentingViewController?.dismiss(animated: true)
}
}
}
extension NewStatusViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
collectionView.collectionViewLayout.invalidateLayout()
}
}
private extension NewStatusViewController {
func changeIdentityButton(identification: Identification) -> UIButton {
let changeIdentityButton = UIButton()
let downsampled = KingfisherOptionsInfo.downsampled(
dimension: .barButtonItemDimension,
scaleFactor: UIScreen.main.scale)
let menuItems = viewModel.authenticatedIdentities
.filter { $0.id != identification.identity.id }
.map { identity in
UIDeferredMenuElement { completion in
let action = UIAction(title: identity.handle) { [weak self] _ in
self?.viewModel.setIdentity(identity)
}
if let image = identity.image {
KingfisherManager.shared.retrieveImage(with: image, options: downsampled) {
if case let .success(value) = $0 {
action.image = value.image
}
completion([action])
}
} else {
completion([action])
}
}
}
changeIdentityButton.kf.setImage(
with: identification.identity.image,
for: .normal,
options: downsampled)
changeIdentityButton.showsMenuAsPrimaryAction = true
changeIdentityButton.menu = UIMenu(children: menuItems)
return changeIdentityButton
}
}

View file

@ -91,11 +91,7 @@ public extension DomainBlocksViewModel {
}
public extension NewStatusViewModel {
static let preview = NewStatusViewModel(
service: .init(
id: identityId,
identityDatabase: db,
environment: environment))
static let preview = RootViewModel.preview.newStatusViewModel(identification: .preview)
}
// swiftlint:enable force_try

View file

@ -0,0 +1,19 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import Mastodon
import ServiceLayer
public final class CompositionViewModel: ObservableObject {
public let composition: Composition
@Published public private(set) var identification: Identification
init(composition: Composition,
identification: Identification,
identificationPublisher: AnyPublisher<Identification, Never>) {
self.composition = composition
self.identification = identification
identificationPublisher.assign(to: &$identification)
}
}

View file

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

View file

@ -153,10 +153,6 @@ public extension NavigationViewModel {
collectionService: identification.service.service(timeline: .bookmarks),
identification: identification)
}
func newStatusViewModel() -> NewStatusViewModel {
NewStatusViewModel(service: identification.service.newStatusService())
}
}
extension NavigationViewModel.Tab: Identifiable {

View file

@ -6,9 +6,53 @@ import Mastodon
import ServiceLayer
public final class NewStatusViewModel: ObservableObject {
private let service: NewStatusService
@Published public private(set) var compositionViewModels = [CompositionViewModel]()
@Published public private(set) var identification: Identification
@Published public private(set) var authenticatedIdentities = [Identity]()
@Published public var canChangeIdentity = true
@Published public var alertItem: AlertItem?
public init(service: NewStatusService) {
self.service = service
private let allIdentitiesService: AllIdentitiesService
private let environment: AppEnvironment
private var cancellables = Set<AnyCancellable>()
public init(allIdentitiesService: AllIdentitiesService,
identification: Identification,
environment: AppEnvironment) {
self.allIdentitiesService = allIdentitiesService
self.identification = identification
self.environment = environment
compositionViewModels = [CompositionViewModel(
composition: .init(id: environment.uuid(), text: ""),
identification: identification,
identificationPublisher: $identification.eraseToAnyPublisher())]
allIdentitiesService.authenticatedIdentitiesPublisher()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$authenticatedIdentities)
}
}
public extension NewStatusViewModel {
func viewModel(indexPath: IndexPath) -> CompositionViewModel {
compositionViewModels[indexPath.row]
}
func setIdentity(_ identity: Identity) {
let identityService: IdentityService
do {
identityService = try allIdentitiesService.identityService(id: identity.id)
} catch {
alertItem = AlertItem(error: error)
return
}
identification = Identification(
identity: identity,
publisher: identityService.identityPublisher(immediate: false)
.assignErrorsToAlertItem(to: \.alertItem, on: self),
service: identityService,
environment: environment)
}
}

View file

@ -57,6 +57,13 @@ public extension RootViewModel {
allIdentitiesService: allIdentitiesService,
instanceURLService: InstanceURLService(environment: environment))
}
func newStatusViewModel(identification: Identification) -> NewStatusViewModel {
NewStatusViewModel(
allIdentitiesService: allIdentitiesService,
identification: identification,
environment: environment)
}
}
private extension RootViewModel {

View file

@ -0,0 +1,41 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import ServiceLayer
public enum ShareExtensionError: Error {
case noAccountFound
}
public final class ShareExtensionNavigationViewModel: ObservableObject {
@Published public var alertItem: AlertItem?
private let environment: AppEnvironment
public init(environment: AppEnvironment) {
self.environment = environment
}
}
public extension ShareExtensionNavigationViewModel {
func newStatusViewModel() throws -> NewStatusViewModel {
let allIdentitiesService = try AllIdentitiesService(environment: environment)
guard let identity = try allIdentitiesService.mostRecentAuthenticatedIdentity()
else { throw ShareExtensionError.noAccountFound }
let identityService = try allIdentitiesService.identityService(id: identity.id)
let identification = Identification(
identity: identity,
publisher: identityService.identityPublisher(immediate: false)
.assignErrorsToAlertItem(to: \.alertItem, on: self),
service: identityService,
environment: environment)
return NewStatusViewModel(
allIdentitiesService: allIdentitiesService,
identification: identification,
environment: environment)
}
}

View file

@ -0,0 +1,18 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
struct CompositionContentConfiguration {
let viewModel: CompositionViewModel
}
extension CompositionContentConfiguration: UIContentConfiguration {
func makeContentView() -> UIView & UIContentView {
CompositionView(configuration: self)
}
func updated(for state: UIConfigurationState) -> CompositionContentConfiguration {
self
}
}

View file

@ -0,0 +1,23 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
class CompositionListCell: UICollectionViewListCell {
var viewModel: CompositionViewModel?
override func updateConfiguration(using state: UICellConfigurationState) {
guard let viewModel = viewModel else { return }
contentConfiguration = CompositionContentConfiguration(viewModel: viewModel).updated(for: state)
backgroundConfiguration = UIBackgroundConfiguration.clear().updated(for: state)
}
override var isSelected: Bool {
didSet {
if isSelected {
(contentView as? CompositionView)?.textView.becomeFirstResponder()
}
}
}
}

View file

@ -0,0 +1,81 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Kingfisher
import UIKit
class CompositionView: UIView {
let avatarImageView = UIImageView()
let textView = UITextView()
private var compositionConfiguration: CompositionContentConfiguration
private var cancellables = Set<AnyCancellable>()
init(configuration: CompositionContentConfiguration) {
self.compositionConfiguration = configuration
super.init(frame: .zero)
initialSetup()
applyCompositionConfiguration()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension CompositionView: UIContentView {
var configuration: UIContentConfiguration {
get { compositionConfiguration }
set {
guard let compositionConfiguration = newValue as? CompositionContentConfiguration else { return }
self.compositionConfiguration = compositionConfiguration
applyCompositionConfiguration()
}
}
}
private extension CompositionView {
func initialSetup() {
addSubview(avatarImageView)
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.layer.cornerRadius = .avatarDimension / 2
avatarImageView.clipsToBounds = true
let stackView = UIStackView()
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.addArrangedSubview(textView)
textView.isScrollEnabled = false
textView.adjustsFontForContentSizeCategory = true
textView.font = .preferredFont(forTextStyle: .body)
textView.textContainer.lineFragmentPadding = 0
NSLayoutConstraint.activate([
avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension),
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),
avatarImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
avatarImageView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: readableContentGuide.bottomAnchor),
stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: .defaultSpacing),
stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor)
])
compositionConfiguration.viewModel.$identification.map(\.identity.image)
.sink { [weak self] in self?.avatarImageView.kf.setImage(with: $0) }
.store(in: &cancellables)
}
func applyCompositionConfiguration() {
}
}

View file

@ -7,7 +7,7 @@ struct NewStatusView: UIViewControllerRepresentable {
let viewModelClosure: () -> NewStatusViewModel
func makeUIViewController(context: Context) -> NewStatusViewController {
NewStatusViewController(viewModel: viewModelClosure())
NewStatusViewController(viewModel: viewModelClosure(), isShareExtension: false)
}
func updateUIViewController(_ uiViewController: NewStatusViewController, context: Context) {

View file

@ -41,9 +41,11 @@ struct TabNavigationView: View {
EmptyView()
.fullScreenCover(isPresented: $viewModel.presentingNewStatus) {
NavigationView {
NewStatusView(viewModelClosure: viewModel.newStatusViewModel)
.edgesIgnoringSafeArea(.all)
.navigationBarTitleDisplayMode(.inline)
NewStatusView {
rootViewModel.newStatusViewModel(identification: viewModel.identification)
}
.edgesIgnoringSafeArea(.all)
.navigationBarTitleDisplayMode(.inline)
}
.navigationViewStyle(StackNavigationViewStyle())
.environmentObject(viewModel)
@ -137,7 +139,9 @@ private extension TabNavigationView {
viewModel.presentingSecondaryNavigation.toggle()
} label: {
KFImage(viewModel.identification.identity.image,
options: .downsampled(dimension: 28, scaleFactor: displayScale))
options: .downsampled(
dimension: .barButtonItemDimension,
scaleFactor: displayScale))
.placeholder { Image(systemName: "gear") }
.renderingMode(.original)
.contextMenu(ContextMenu {
@ -149,7 +153,9 @@ private extension TabNavigationView {
title: { Text(recentIdentity.handle) },
icon: {
KFImage(recentIdentity.image,
options: .downsampled(dimension: 28, scaleFactor: displayScale))
options: .downsampled(
dimension: .barButtonItemDimension,
scaleFactor: displayScale))
.renderingMode(.original)
})
}

View file

@ -10,6 +10,7 @@ extension CGFloat {
static let avatarDimension: Self = 50
static let hairline = 1 / UIScreen.main.scale
static let minimumButtonDimension: Self = 44
static let barButtonItemDimension: Self = 28
}
extension TimeInterval {