Posting media WIP

This commit is contained in:
Justin Mazzocchi 2020-12-15 17:39:38 -08:00
parent 5bb5021a69
commit d7c73ee06d
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
19 changed files with 557 additions and 33 deletions

View File

@ -1,20 +1,38 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import GRDB
import Mastodon
public class Composition {
public let id: Id
public var text: String
@Published public var text: String
@Published public var attachments: [Attachment]
public init(id: Id, text: String) {
self.id = id
self.text = text
attachments = []
}
}
public extension Composition {
typealias Id = UUID
struct Attachment {
public let data: Data
public let type: Mastodon.Attachment.AttachmentType
public let mimeType: String
public var description: String?
public var focus: Mastodon.Attachment.Meta.Focus?
public init(data: Data, type: Mastodon.Attachment.AttachmentType, mimeType: String) {
self.data = data
self.type = type
self.mimeType = mimeType
}
}
}
extension Composition {

View File

@ -4,6 +4,9 @@ import UIKit
import ViewModels
final class NewStatusDataSource: UICollectionViewDiffableDataSource<Int, Composition.Id> {
private let updateQueue =
DispatchQueue(label: "com.metabolist.metatext.new-status-data-source.update-queue")
init(collectionView: UICollectionView, viewModelProvider: @escaping (IndexPath) -> CompositionViewModel) {
let registration = UICollectionView.CellRegistration<CompositionListCell, CompositionViewModel> {
$0.viewModel = $2
@ -16,4 +19,12 @@ final class NewStatusDataSource: UICollectionViewDiffableDataSource<Int, Composi
item: viewModelProvider(indexPath))
}
}
override func apply(_ snapshot: NSDiffableDataSourceSnapshot<Int, Composition.Id>,
animatingDifferences: Bool = true,
completion: (() -> Void)? = nil) {
updateQueue.async {
super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion)
}
}
}

View File

@ -0,0 +1,19 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
extension UIViewController {
func present(alertItem: AlertItem) {
let alertController = UIAlertController(
title: nil,
message: alertItem.error.localizedDescription,
preferredStyle: .alert)
let okAction = UIAlertAction(title: NSLocalizedString("ok", comment: ""), style: .default) { _ in }
alertController.addAction(okAction)
present(alertController, animated: true)
}
}

View File

@ -0,0 +1,27 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public enum MultipartFormValue {
case string(String)
case data(Data, filename: String, mimeType: String)
}
extension MultipartFormValue {
func httpBodyComponent(boundary: String, key: String) -> Data {
switch self {
case let .string(value):
return Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n".utf8)
case let .data(data, filename, mimeType):
var component = Data()
component.append(Data("--\(boundary)\r\n".utf8))
component.append(Data("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(filename)\"\r\n".utf8))
component.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
component.append(data)
component.append(Data("\r\n".utf8))
return component
}
}
}

View File

@ -8,6 +8,7 @@ public protocol Target {
var method: HTTPMethod { get }
var queryParameters: [URLQueryItem] { get }
var jsonBody: [String: Any]? { get }
var multipartFormData: [String: MultipartFormValue]? { get }
var headers: [String: String]? { get }
}
@ -35,6 +36,17 @@ public extension Target {
if let jsonBody = jsonBody {
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: jsonBody)
urlRequest.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
} else if let multipartFormData = multipartFormData {
let boundary = "Boundary-\(UUID().uuidString)"
var httpBody = Data()
for (key, value) in multipartFormData {
httpBody.append(value.httpBodyComponent(boundary: boundary, key: key))
}
httpBody.append(Data("--\(boundary)--".utf8))
urlRequest.httpBody = httpBody
urlRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
}
return urlRequest

View File

@ -58,6 +58,7 @@
"messages" = "Messages";
"ok" = "OK";
"pending.pending-confirmation" = "Your account is pending confirmation";
"post" = "Post";
"preferences" = "Preferences";
"preferences.app" = "App Preferences";
"preferences.blocked-domains" = "Blocked Domains";

View File

@ -11,6 +11,7 @@ public protocol Endpoint {
var method: HTTPMethod { get }
var queryParameters: [URLQueryItem] { get }
var jsonBody: [String: Any]? { get }
var multipartFormData: [String: MultipartFormValue]? { get }
var headers: [String: String]? { get }
}
@ -33,5 +34,7 @@ public extension Endpoint {
var jsonBody: [String: Any]? { nil }
var multipartFormData: [String: MultipartFormValue]? { nil }
var headers: [String: String]? { nil }
}

View File

@ -0,0 +1,50 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum AttachmentEndpoint {
case create(data: Data, mimeType: String, description: String?, focus: Attachment.Meta.Focus?)
}
extension AttachmentEndpoint: Endpoint {
public typealias ResultType = Attachment
public var context: [String] {
defaultContext + ["media"]
}
public var pathComponentsInContext: [String] {
switch self {
case .create:
return []
}
}
public var multipartFormData: [String: MultipartFormValue]? {
switch self {
case let .create(data, mimeType, description, focus):
var params = [String: MultipartFormValue]()
params["file"] = .data(data, filename: UUID().uuidString, mimeType: mimeType)
if let description = description {
params["description"] = .string(description)
}
if let focus = focus {
params["focus"] = .string("\(focus.x),\(focus.y)")
}
return params
}
}
public var method: HTTPMethod {
switch self {
case .create:
return .post
}
}
}

View File

@ -10,6 +10,25 @@ public enum StatusEndpoint {
case unfavourite(id: Status.Id)
case bookmark(id: Status.Id)
case unbookmark(id: Status.Id)
case post(Components)
}
public extension StatusEndpoint {
struct Components {
public var text: String?
public init() {}
}
}
extension StatusEndpoint.Components {
var jsonBody: [String: Any]? {
var params = [String: Any]()
params["status"] = text
return params
}
}
extension StatusEndpoint: Endpoint {
@ -31,6 +50,17 @@ extension StatusEndpoint: Endpoint {
return [id, "bookmark"]
case let .unbookmark(id):
return [id, "unbookmark"]
case .post:
return []
}
}
public var jsonBody: [String: Any]? {
switch self {
case let .post(components):
return components.jsonBody
default:
return nil
}
}
@ -38,7 +68,7 @@ extension StatusEndpoint: Endpoint {
switch self {
case .status:
return .get
case .favourite, .unfavourite, .bookmark, .unbookmark:
case .favourite, .unfavourite, .bookmark, .unbookmark, .post:
return .post
}
}

View File

@ -26,6 +26,8 @@ extension MastodonAPITarget: DecodableTarget {
public var jsonBody: [String: Any]? { endpoint.jsonBody }
public var multipartFormData: [String: MultipartFormValue]? { endpoint.multipartFormData }
public var headers: [String: String]? {
var headers = endpoint.headers

View File

@ -98,6 +98,10 @@
D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DA2529319100FA1D72 /* LoadMoreView.swift */; };
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */; };
D0E7AD3925870B13005F5E2D /* UIVIewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */; };
D0E7AD4225870C79005F5E2D /* UIVIewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */; };
D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */; };
D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */; };
D0EA59402522AC8700804347 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA593F2522AC8700804347 /* CardView.swift */; };
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; };
D0F0B10E251A868200942152 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B10D251A868200942152 /* AccountView.swift */; };
@ -252,6 +256,8 @@
D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = "<group>"; };
D0E569DA2529319100FA1D72 /* LoadMoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreView.swift; sourceTree = "<group>"; };
D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreContentConfiguration.swift; sourceTree = "<group>"; };
D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIVIewController+Extensions.swift"; sourceTree = "<group>"; };
D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompositionInputAccessoryView.swift; sourceTree = "<group>"; };
D0EA593F2522AC8700804347 /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = "<group>"; };
D0EA59472522B8B600804347 /* ViewConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewConstants.swift; sourceTree = "<group>"; };
D0F0B10D251A868200942152 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
@ -427,6 +433,7 @@
D0F0B10D251A868200942152 /* AccountView.swift */,
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */,
D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */,
D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */,
D08E52ED257D757100FA2C5F /* CompositionView.swift */,
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
@ -514,6 +521,7 @@
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */,
D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */,
D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */,
D0030981250C6C8500EACB32 /* URL+Extensions.swift */,
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */,
);
@ -755,6 +763,7 @@
D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */,
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */,
D0E7AD3925870B13005F5E2D /* UIVIewController+Extensions.swift in Sources */,
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */,
D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */,
@ -768,6 +777,7 @@
D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */,
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */,
D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */,
D0625E59250F092900502611 /* StatusListCell.swift in Sources */,
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */,
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
@ -836,8 +846,10 @@
D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */,
D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */,
D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */,
D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */,
D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */,
D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */,
D0E7AD4225870C79005F5E2D /* UIVIewController+Extensions.swift in Sources */,
D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */,
D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */,

View File

@ -206,6 +206,28 @@ public extension IdentityService {
.eraseToAnyPublisher()
}
func post(compositions: [Composition]) -> AnyPublisher<Never, Error> {
guard let composition = compositions.first else { fatalError() }
guard let attachment = composition.attachments.first else { fatalError() }
return mastodonAPIClient.request(AttachmentEndpoint.create(
data: attachment.data,
mimeType: attachment.mimeType,
description: attachment.description,
focus: attachment.focus))
.print()
.ignoreOutput()
.eraseToAnyPublisher()
// var components = StatusEndpoint.Components()
//
// if !composition.text.isEmpty {
// components.text = composition.text
// }
//
// return mastodonAPIClient.request(StatusEndpoint.post(components))
// .ignoreOutput()
// .eraseToAnyPublisher()
}
func service(timeline: Timeline) -> TimelineService {
TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}

View File

@ -75,6 +75,7 @@ private struct UpdatedFilterTarget: DecodableTarget {
let method = HTTPMethod.get
let queryParameters: [URLQueryItem] = []
let jsonBody: [String: Any]? = nil
let multipartFormData: [String: MultipartFormValue]? = nil
let headers: [String: String]? = nil
}

View File

@ -0,0 +1,100 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import ImageIO
import Mastodon
import UniformTypeIdentifiers
enum MediaProcessingError: Error {
case invalidMimeType
case fileURLNotFound
case unsupportedType
case unableToCreateImageSource
case unableToDownsample
case unableToCreateImageDataDestination
}
public struct MediaProcessingService {}
public extension MediaProcessingService {
static func attachment(itemProvider: NSItemProvider) -> AnyPublisher<Composition.Attachment, Error> {
let registeredTypes = itemProvider.registeredTypeIdentifiers.compactMap(UTType.init)
guard let uniformType = registeredTypes.first(where: {
guard let mimeType = $0.preferredMIMEType else { return false }
return !Self.unuploadableMimeTypes.contains(mimeType)
}),
let mimeType = uniformType.preferredMIMEType else {
return Fail(error: MediaProcessingError.invalidMimeType).eraseToAnyPublisher()
}
let type: Attachment.AttachmentType
if uniformType.conforms(to: .image) {
type = .image
} else if uniformType.conforms(to: .movie) {
type = .video
} else if uniformType.conforms(to: .audio) {
type = .audio
} else if uniformType.conforms(to: .video), uniformType == .mpeg4Movie {
type = .gifv
} else {
type = .unknown
}
return Future<Data, Error> { promise in
itemProvider.loadFileRepresentation(forTypeIdentifier: uniformType.identifier) { url, error in
if let error = error {
return promise(.failure(error))
}
guard let url = url else { return promise(.failure(MediaProcessingError.fileURLNotFound)) }
if uniformType.conforms(to: .image) {
return promise(imageData(url: url, type: uniformType))
} else {
do {
return try promise(.success(Data(contentsOf: url)))
} catch {
return promise(.failure(error))
}
}
}
}
.map { Composition.Attachment(data: $0, type: type, mimeType: mimeType) }
.eraseToAnyPublisher()
}
}
private extension MediaProcessingService {
static let unuploadableMimeTypes: Set<String> = [UTType.heic.preferredMIMEType!]
static let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
static let thumbnailOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 1280
] as CFDictionary
static func imageData(url: URL, type: UTType) -> Result<Data, Error> {
guard let source = CGImageSourceCreateWithURL(url as CFURL, Self.imageSourceOptions) else {
return .failure(MediaProcessingError.unableToCreateImageSource)
}
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions) else {
return .failure(MediaProcessingError.unableToDownsample)
}
let data = NSMutableData()
guard let imageDestination = CGImageDestinationCreateWithData(data, type.identifier as CFString, 1, nil) else {
return .failure(MediaProcessingError.unableToCreateImageDataDestination)
}
CGImageDestinationAddImage(imageDestination, image, nil)
CGImageDestinationFinalize(imageDestination)
return .success(data as Data)
}
}

View File

@ -2,12 +2,19 @@
import Combine
import Kingfisher
import PhotosUI
import UIKit
import ViewModels
class NewStatusViewController: UICollectionViewController {
private let viewModel: NewStatusViewModel
private let isShareExtension: Bool
private let postButton = UIBarButtonItem(
title: NSLocalizedString("post", comment: ""),
style: .done,
target: nil,
action: nil)
private var attachMediaTo: CompositionViewModel?
private var cancellables = Set<AnyCancellable>()
private lazy var dataSource: NewStatusDataSource = {
@ -22,14 +29,6 @@ class NewStatusViewController: UICollectionViewController {
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)
@ -44,22 +43,49 @@ class NewStatusViewController: UICollectionViewController {
view.backgroundColor = .systemBackground
postButton.primaryAction = UIAction(title: NSLocalizedString("post", comment: "")) { [weak self] _ in
self?.viewModel.post()
}
setupBarButtonItems(identification: viewModel.identification)
viewModel.$identification
.sink { [weak self] in
guard let self = self else { return }
self.setupBarButtonItems(identification: $0)
}
.store(in: &cancellables)
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)
}
guard let self = self else { return }
let oldSnapshot = self.dataSource.snapshot()
let newSnapshot = [$0.map(\.composition.id)].snapshot()
let diff = newSnapshot.itemIdentifiers.difference(from: oldSnapshot.itemIdentifiers)
self.dataSource.apply(newSnapshot) {
if case let .insert(_, id, _) = diff.insertions.first,
let indexPath = self.dataSource.indexPath(for: id) {
self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .top)
}
}
}
.store(in: &cancellables)
viewModel.$compositionViewModels
.flatMap { Publishers.MergeMany($0.map(\.composition.$text)) }
.sink { [weak self] _ in self?.collectionView.collectionViewLayout.invalidateLayout() }
.store(in: &cancellables)
viewModel.$canPost.sink { [weak self] in self?.postButton.isEnabled = $0 }.store(in: &cancellables)
viewModel.events.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables)
viewModel.$alertItem
.compactMap { $0 }
.sink { [weak self] in self?.present(alertItem: $0) }
.store(in: &cancellables)
}
override func didMove(toParent parent: UIViewController?) {
@ -68,12 +94,6 @@ class NewStatusViewController: UICollectionViewController {
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(
@ -84,6 +104,7 @@ class NewStatusViewController: UICollectionViewController {
target?.navigationItem.titleView = viewModel.canChangeIdentity
? changeIdentityButton(identification: identification)
: nil
target?.navigationItem.rightBarButtonItem = postButton
}
func dismiss() {
@ -95,9 +116,14 @@ class NewStatusViewController: UICollectionViewController {
}
}
extension NewStatusViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
collectionView.collectionViewLayout.invalidateLayout()
extension NewStatusViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
dismiss(animated: true)
guard let result = results.first else { return }
attachMediaTo?.attach(itemProvider: result.itemProvider)
attachMediaTo = nil
}
}
@ -139,4 +165,22 @@ private extension NewStatusViewController {
return changeIdentityButton
}
func handle(event: CompositionViewModel.Event) {
switch event {
case let .presentMediaPicker(compositionViewModel):
attachMediaTo = compositionViewModel
var configuration = PHPickerConfiguration()
configuration.preferredAssetRepresentationMode = .current
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
default:
break
}
}
}

View File

@ -7,13 +7,39 @@ import ServiceLayer
public final class CompositionViewModel: ObservableObject {
public let composition: Composition
@Published public private(set) var isPostable = false
@Published public private(set) var identification: Identification
private let eventsSubject: PassthroughSubject<Event, Never>
init(composition: Composition,
identification: Identification,
identificationPublisher: AnyPublisher<Identification, Never>) {
identificationPublisher: AnyPublisher<Identification, Never>,
eventsSubject: PassthroughSubject<Event, Never>) {
self.composition = composition
self.identification = identification
self.eventsSubject = eventsSubject
identificationPublisher.assign(to: &$identification)
composition.$text.map { !$0.isEmpty }.removeDuplicates().assign(to: &$isPostable)
}
}
public extension CompositionViewModel {
enum Event {
case insertAfter(CompositionViewModel)
case presentMediaPicker(CompositionViewModel)
case attach(itemProvider: NSItemProvider, viewModel: CompositionViewModel)
}
func presentMediaPicker() {
eventsSubject.send(.presentMediaPicker(self))
}
func insert() {
eventsSubject.send(.insertAfter(self))
}
func attach(itemProvider: NSItemProvider) {
eventsSubject.send(.attach(itemProvider: itemProvider, viewModel: self))
}
}

View File

@ -9,11 +9,16 @@ public final class NewStatusViewModel: ObservableObject {
@Published public private(set) var compositionViewModels = [CompositionViewModel]()
@Published public private(set) var identification: Identification
@Published public private(set) var authenticatedIdentities = [Identity]()
@Published public var canPost = false
@Published public var canChangeIdentity = true
@Published public var alertItem: AlertItem?
@Published public private(set) var loading = false
public let events: AnyPublisher<CompositionViewModel.Event, Never>
private let allIdentitiesService: AllIdentitiesService
private let environment: AppEnvironment
private let eventsSubject = PassthroughSubject<CompositionViewModel.Event, Never>()
private let itemEventsSubject = PassthroughSubject<CompositionViewModel.Event, Never>()
private var cancellables = Set<AnyCancellable>()
public init(allIdentitiesService: AllIdentitiesService,
@ -22,13 +27,18 @@ public final class NewStatusViewModel: ObservableObject {
self.allIdentitiesService = allIdentitiesService
self.identification = identification
self.environment = environment
compositionViewModels = [CompositionViewModel(
composition: .init(id: environment.uuid(), text: ""),
identification: identification,
identificationPublisher: $identification.eraseToAnyPublisher())]
events = eventsSubject.eraseToAnyPublisher()
compositionViewModels = [newCompositionViewModel()]
itemEventsSubject.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables)
allIdentitiesService.authenticatedIdentitiesPublisher()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$authenticatedIdentities)
$compositionViewModels.flatMap { Publishers.MergeMany($0.map(\.$isPostable)) }
.receive(on: DispatchQueue.main) // hack to punt to next run loop, consider refactoring
.compactMap { [weak self] _ in self?.compositionViewModels.allSatisfy(\.isPostable) }
.combineLatest($loading)
.map { $0 && !$1 }
.assign(to: &$canPost)
}
}
@ -55,4 +65,47 @@ public extension NewStatusViewModel {
service: identityService,
environment: environment)
}
func post() {
identification.service.post(compositions: compositionViewModels.map(\.composition))
.receive(on: DispatchQueue.main)
.handleEvents(
receiveSubscription: { [weak self] _ in self?.loading = true },
receiveCompletion: { [weak self] _ in self?.loading = false })
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
}
private extension NewStatusViewModel {
func newCompositionViewModel() -> CompositionViewModel {
CompositionViewModel(
composition: .init(id: environment.uuid(), text: ""),
identification: identification,
identificationPublisher: $identification.eraseToAnyPublisher(),
eventsSubject: itemEventsSubject)
}
func handle(event: CompositionViewModel.Event) {
switch event {
case let .insertAfter(viewModel):
guard let index = compositionViewModels.firstIndex(where: { $0 === viewModel }) else { return }
let newViewModel = newCompositionViewModel()
if index >= compositionViewModels.count - 1 {
compositionViewModels.append(newViewModel)
} else {
compositionViewModels.insert(newViewModel, at: index + 1)
}
case let .attach(itemProvider, viewModel):
MediaProcessingService.attachment(itemProvider: itemProvider)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { viewModel.composition.attachments.append($0) }
.store(in: &cancellables)
default:
eventsSubject.send(event)
}
}
}

View File

@ -0,0 +1,84 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import UIKit
import ViewModels
class CompositionInputAccessoryView: UIView {
private let stackView = UIStackView()
private let viewModel: CompositionViewModel
private var cancellables = Set<AnyCancellable>()
init(viewModel: CompositionViewModel) {
self.viewModel = viewModel
super.init(frame: .zero)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
}
}
private extension CompositionInputAccessoryView {
func initialSetup() {
autoresizingMask = .flexibleHeight
backgroundColor = .secondarySystemFill
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = .defaultSpacing
let mediaButton = UIButton()
stackView.addArrangedSubview(mediaButton)
mediaButton.setImage(
UIImage(
systemName: "photo",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .normal)
mediaButton.addAction(UIAction { [weak self] _ in self?.viewModel.presentMediaPicker() }, for: .touchUpInside)
let pollButton = UIButton()
stackView.addArrangedSubview(pollButton)
pollButton.setImage(
UIImage(
systemName: "chart.bar.xaxis",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .normal)
stackView.addArrangedSubview(UIView())
let addButton = UIButton()
stackView.addArrangedSubview(addButton)
addButton.setImage(
UIImage(
systemName: "plus.circle.fill",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .normal)
addButton.addAction(UIAction { [weak self] _ in self?.viewModel.insert() }, for: .touchUpInside)
for button in [mediaButton, pollButton, addButton] {
button.heightAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension).isActive = true
button.widthAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension).isActive = true
}
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
viewModel.$isPostable.sink { addButton.isEnabled = $0 }.store(in: &cancellables)
}
}

View File

@ -39,6 +39,12 @@ extension CompositionView: UIContentView {
}
}
extension CompositionView: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
compositionConfiguration.viewModel.composition.text = textView.text
}
}
private extension CompositionView {
func initialSetup() {
addSubview(avatarImageView)
@ -57,6 +63,9 @@ private extension CompositionView {
textView.adjustsFontForContentSizeCategory = true
textView.font = .preferredFont(forTextStyle: .body)
textView.textContainer.lineFragmentPadding = 0
textView.inputAccessoryView = CompositionInputAccessoryView(viewModel: compositionConfiguration.viewModel)
textView.inputAccessoryView?.sizeToFit()
textView.delegate = self
let constraints = [
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),