mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-22 00:01:00 +00:00
Posting media WIP
This commit is contained in:
parent
5bb5021a69
commit
d7c73ee06d
19 changed files with 557 additions and 33 deletions
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
19
Extensions/UIVIewController+Extensions.swift
Normal file
19
Extensions/UIVIewController+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
27
HTTP/Sources/HTTP/MultipartFormValue.swift
Normal file
27
HTTP/Sources/HTTP/MultipartFormValue.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
84
Views/CompositionInputAccessoryView.swift
Normal file
84
Views/CompositionInputAccessoryView.swift
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
|
|
Loading…
Reference in a new issue