mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41: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.
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import GRDB
|
import GRDB
|
||||||
|
import Mastodon
|
||||||
|
|
||||||
public class Composition {
|
public class Composition {
|
||||||
public let id: Id
|
public let id: Id
|
||||||
public var text: String
|
@Published public var text: String
|
||||||
|
@Published public var attachments: [Attachment]
|
||||||
|
|
||||||
public init(id: Id, text: String) {
|
public init(id: Id, text: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.text = text
|
self.text = text
|
||||||
|
attachments = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Composition {
|
public extension Composition {
|
||||||
typealias Id = UUID
|
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 {
|
extension Composition {
|
||||||
|
|
|
@ -4,6 +4,9 @@ import UIKit
|
||||||
import ViewModels
|
import ViewModels
|
||||||
|
|
||||||
final class NewStatusDataSource: UICollectionViewDiffableDataSource<Int, Composition.Id> {
|
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) {
|
init(collectionView: UICollectionView, viewModelProvider: @escaping (IndexPath) -> CompositionViewModel) {
|
||||||
let registration = UICollectionView.CellRegistration<CompositionListCell, CompositionViewModel> {
|
let registration = UICollectionView.CellRegistration<CompositionListCell, CompositionViewModel> {
|
||||||
$0.viewModel = $2
|
$0.viewModel = $2
|
||||||
|
@ -16,4 +19,12 @@ final class NewStatusDataSource: UICollectionViewDiffableDataSource<Int, Composi
|
||||||
item: viewModelProvider(indexPath))
|
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 method: HTTPMethod { get }
|
||||||
var queryParameters: [URLQueryItem] { get }
|
var queryParameters: [URLQueryItem] { get }
|
||||||
var jsonBody: [String: Any]? { get }
|
var jsonBody: [String: Any]? { get }
|
||||||
|
var multipartFormData: [String: MultipartFormValue]? { get }
|
||||||
var headers: [String: String]? { get }
|
var headers: [String: String]? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,6 +36,17 @@ public extension Target {
|
||||||
if let jsonBody = jsonBody {
|
if let jsonBody = jsonBody {
|
||||||
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: jsonBody)
|
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: jsonBody)
|
||||||
urlRequest.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
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
|
return urlRequest
|
||||||
|
|
|
@ -58,6 +58,7 @@
|
||||||
"messages" = "Messages";
|
"messages" = "Messages";
|
||||||
"ok" = "OK";
|
"ok" = "OK";
|
||||||
"pending.pending-confirmation" = "Your account is pending confirmation";
|
"pending.pending-confirmation" = "Your account is pending confirmation";
|
||||||
|
"post" = "Post";
|
||||||
"preferences" = "Preferences";
|
"preferences" = "Preferences";
|
||||||
"preferences.app" = "App Preferences";
|
"preferences.app" = "App Preferences";
|
||||||
"preferences.blocked-domains" = "Blocked Domains";
|
"preferences.blocked-domains" = "Blocked Domains";
|
||||||
|
|
|
@ -11,6 +11,7 @@ public protocol Endpoint {
|
||||||
var method: HTTPMethod { get }
|
var method: HTTPMethod { get }
|
||||||
var queryParameters: [URLQueryItem] { get }
|
var queryParameters: [URLQueryItem] { get }
|
||||||
var jsonBody: [String: Any]? { get }
|
var jsonBody: [String: Any]? { get }
|
||||||
|
var multipartFormData: [String: MultipartFormValue]? { get }
|
||||||
var headers: [String: String]? { get }
|
var headers: [String: String]? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,5 +34,7 @@ public extension Endpoint {
|
||||||
|
|
||||||
var jsonBody: [String: Any]? { nil }
|
var jsonBody: [String: Any]? { nil }
|
||||||
|
|
||||||
|
var multipartFormData: [String: MultipartFormValue]? { nil }
|
||||||
|
|
||||||
var headers: [String: String]? { 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 unfavourite(id: Status.Id)
|
||||||
case bookmark(id: Status.Id)
|
case bookmark(id: Status.Id)
|
||||||
case unbookmark(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 {
|
extension StatusEndpoint: Endpoint {
|
||||||
|
@ -31,6 +50,17 @@ extension StatusEndpoint: Endpoint {
|
||||||
return [id, "bookmark"]
|
return [id, "bookmark"]
|
||||||
case let .unbookmark(id):
|
case let .unbookmark(id):
|
||||||
return [id, "unbookmark"]
|
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 {
|
switch self {
|
||||||
case .status:
|
case .status:
|
||||||
return .get
|
return .get
|
||||||
case .favourite, .unfavourite, .bookmark, .unbookmark:
|
case .favourite, .unfavourite, .bookmark, .unbookmark, .post:
|
||||||
return .post
|
return .post
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,8 @@ extension MastodonAPITarget: DecodableTarget {
|
||||||
|
|
||||||
public var jsonBody: [String: Any]? { endpoint.jsonBody }
|
public var jsonBody: [String: Any]? { endpoint.jsonBody }
|
||||||
|
|
||||||
|
public var multipartFormData: [String: MultipartFormValue]? { endpoint.multipartFormData }
|
||||||
|
|
||||||
public var headers: [String: String]? {
|
public var headers: [String: String]? {
|
||||||
var headers = endpoint.headers
|
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, ); }; };
|
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 */; };
|
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DA2529319100FA1D72 /* LoadMoreView.swift */; };
|
||||||
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.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 */; };
|
D0EA59402522AC8700804347 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA593F2522AC8700804347 /* CardView.swift */; };
|
||||||
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; };
|
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; };
|
||||||
D0F0B10E251A868200942152 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B10D251A868200942152 /* AccountView.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D0F0B10D251A868200942152 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -427,6 +433,7 @@
|
||||||
D0F0B10D251A868200942152 /* AccountView.swift */,
|
D0F0B10D251A868200942152 /* AccountView.swift */,
|
||||||
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
|
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
|
||||||
D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */,
|
D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */,
|
||||||
|
D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */,
|
||||||
D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */,
|
D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */,
|
||||||
D08E52ED257D757100FA2C5F /* CompositionView.swift */,
|
D08E52ED257D757100FA2C5F /* CompositionView.swift */,
|
||||||
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
|
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
|
||||||
|
@ -514,6 +521,7 @@
|
||||||
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
|
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
|
||||||
D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */,
|
D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */,
|
||||||
D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */,
|
D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */,
|
||||||
|
D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */,
|
||||||
D0030981250C6C8500EACB32 /* URL+Extensions.swift */,
|
D0030981250C6C8500EACB32 /* URL+Extensions.swift */,
|
||||||
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */,
|
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */,
|
||||||
);
|
);
|
||||||
|
@ -755,6 +763,7 @@
|
||||||
D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */,
|
D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */,
|
||||||
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
|
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
|
||||||
D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */,
|
D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */,
|
||||||
|
D0E7AD3925870B13005F5E2D /* UIVIewController+Extensions.swift in Sources */,
|
||||||
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
|
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
|
||||||
D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */,
|
D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */,
|
||||||
D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */,
|
D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */,
|
||||||
|
@ -768,6 +777,7 @@
|
||||||
D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */,
|
D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */,
|
||||||
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
|
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
|
||||||
D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */,
|
D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */,
|
||||||
|
D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */,
|
||||||
D0625E59250F092900502611 /* StatusListCell.swift in Sources */,
|
D0625E59250F092900502611 /* StatusListCell.swift in Sources */,
|
||||||
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */,
|
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */,
|
||||||
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
|
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
|
||||||
|
@ -836,8 +846,10 @@
|
||||||
D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */,
|
D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */,
|
||||||
D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */,
|
D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */,
|
||||||
D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */,
|
D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */,
|
||||||
|
D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */,
|
||||||
D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */,
|
D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */,
|
||||||
D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */,
|
D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */,
|
||||||
|
D0E7AD4225870C79005F5E2D /* UIVIewController+Extensions.swift in Sources */,
|
||||||
D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */,
|
D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */,
|
||||||
D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
|
D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
|
||||||
D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */,
|
D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */,
|
||||||
|
|
|
@ -206,6 +206,28 @@ public extension IdentityService {
|
||||||
.eraseToAnyPublisher()
|
.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 {
|
func service(timeline: Timeline) -> TimelineService {
|
||||||
TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,6 +75,7 @@ private struct UpdatedFilterTarget: DecodableTarget {
|
||||||
let method = HTTPMethod.get
|
let method = HTTPMethod.get
|
||||||
let queryParameters: [URLQueryItem] = []
|
let queryParameters: [URLQueryItem] = []
|
||||||
let jsonBody: [String: Any]? = nil
|
let jsonBody: [String: Any]? = nil
|
||||||
|
let multipartFormData: [String: MultipartFormValue]? = nil
|
||||||
let headers: [String: String]? = 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 Combine
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
|
import PhotosUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import ViewModels
|
import ViewModels
|
||||||
|
|
||||||
class NewStatusViewController: UICollectionViewController {
|
class NewStatusViewController: UICollectionViewController {
|
||||||
private let viewModel: NewStatusViewModel
|
private let viewModel: NewStatusViewModel
|
||||||
private let isShareExtension: Bool
|
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 var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
private lazy var dataSource: NewStatusDataSource = {
|
private lazy var dataSource: NewStatusDataSource = {
|
||||||
|
@ -22,14 +29,6 @@ class NewStatusViewController: UICollectionViewController {
|
||||||
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
|
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
|
||||||
|
|
||||||
super.init(collectionViewLayout: layout)
|
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)
|
@available(*, unavailable)
|
||||||
|
@ -44,22 +43,49 @@ class NewStatusViewController: UICollectionViewController {
|
||||||
|
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
|
postButton.primaryAction = UIAction(title: NSLocalizedString("post", comment: "")) { [weak self] _ in
|
||||||
|
self?.viewModel.post()
|
||||||
|
}
|
||||||
|
|
||||||
setupBarButtonItems(identification: viewModel.identification)
|
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
|
viewModel.$compositionViewModels.sink { [weak self] in
|
||||||
self?.dataSource.apply([$0.map(\.composition.id)].snapshot()) {
|
guard let self = self else { return }
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let collectionView = self?.collectionView,
|
let oldSnapshot = self.dataSource.snapshot()
|
||||||
collectionView.indexPathsForSelectedItems?.isEmpty ?? false {
|
let newSnapshot = [$0.map(\.composition.id)].snapshot()
|
||||||
collectionView.selectItem(
|
let diff = newSnapshot.itemIdentifiers.difference(from: oldSnapshot.itemIdentifiers)
|
||||||
at: collectionView.indexPathsForVisibleItems.first,
|
|
||||||
animated: false,
|
self.dataSource.apply(newSnapshot) {
|
||||||
scrollPosition: .top)
|
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)
|
.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?) {
|
override func didMove(toParent parent: UIViewController?) {
|
||||||
|
@ -68,12 +94,6 @@ class NewStatusViewController: UICollectionViewController {
|
||||||
setupBarButtonItems(identification: viewModel.identification)
|
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) {
|
func setupBarButtonItems(identification: Identification) {
|
||||||
let target = isShareExtension ? self : parent
|
let target = isShareExtension ? self : parent
|
||||||
let closeButton = UIBarButtonItem(
|
let closeButton = UIBarButtonItem(
|
||||||
|
@ -84,6 +104,7 @@ class NewStatusViewController: UICollectionViewController {
|
||||||
target?.navigationItem.titleView = viewModel.canChangeIdentity
|
target?.navigationItem.titleView = viewModel.canChangeIdentity
|
||||||
? changeIdentityButton(identification: identification)
|
? changeIdentityButton(identification: identification)
|
||||||
: nil
|
: nil
|
||||||
|
target?.navigationItem.rightBarButtonItem = postButton
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismiss() {
|
func dismiss() {
|
||||||
|
@ -95,9 +116,14 @@ class NewStatusViewController: UICollectionViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NewStatusViewController: UITextViewDelegate {
|
extension NewStatusViewController: PHPickerViewControllerDelegate {
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
collectionView.collectionViewLayout.invalidateLayout()
|
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
|
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 final class CompositionViewModel: ObservableObject {
|
||||||
public let composition: Composition
|
public let composition: Composition
|
||||||
|
@Published public private(set) var isPostable = false
|
||||||
@Published public private(set) var identification: Identification
|
@Published public private(set) var identification: Identification
|
||||||
|
|
||||||
|
private let eventsSubject: PassthroughSubject<Event, Never>
|
||||||
|
|
||||||
init(composition: Composition,
|
init(composition: Composition,
|
||||||
identification: Identification,
|
identification: Identification,
|
||||||
identificationPublisher: AnyPublisher<Identification, Never>) {
|
identificationPublisher: AnyPublisher<Identification, Never>,
|
||||||
|
eventsSubject: PassthroughSubject<Event, Never>) {
|
||||||
self.composition = composition
|
self.composition = composition
|
||||||
self.identification = identification
|
self.identification = identification
|
||||||
|
self.eventsSubject = eventsSubject
|
||||||
identificationPublisher.assign(to: &$identification)
|
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 compositionViewModels = [CompositionViewModel]()
|
||||||
@Published public private(set) var identification: Identification
|
@Published public private(set) var identification: Identification
|
||||||
@Published public private(set) var authenticatedIdentities = [Identity]()
|
@Published public private(set) var authenticatedIdentities = [Identity]()
|
||||||
|
@Published public var canPost = false
|
||||||
@Published public var canChangeIdentity = true
|
@Published public var canChangeIdentity = true
|
||||||
@Published public var alertItem: AlertItem?
|
@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 allIdentitiesService: AllIdentitiesService
|
||||||
private let environment: AppEnvironment
|
private let environment: AppEnvironment
|
||||||
|
private let eventsSubject = PassthroughSubject<CompositionViewModel.Event, Never>()
|
||||||
|
private let itemEventsSubject = PassthroughSubject<CompositionViewModel.Event, Never>()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
public init(allIdentitiesService: AllIdentitiesService,
|
public init(allIdentitiesService: AllIdentitiesService,
|
||||||
|
@ -22,13 +27,18 @@ public final class NewStatusViewModel: ObservableObject {
|
||||||
self.allIdentitiesService = allIdentitiesService
|
self.allIdentitiesService = allIdentitiesService
|
||||||
self.identification = identification
|
self.identification = identification
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
compositionViewModels = [CompositionViewModel(
|
events = eventsSubject.eraseToAnyPublisher()
|
||||||
composition: .init(id: environment.uuid(), text: ""),
|
compositionViewModels = [newCompositionViewModel()]
|
||||||
identification: identification,
|
itemEventsSubject.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables)
|
||||||
identificationPublisher: $identification.eraseToAnyPublisher())]
|
|
||||||
allIdentitiesService.authenticatedIdentitiesPublisher()
|
allIdentitiesService.authenticatedIdentitiesPublisher()
|
||||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||||
.assign(to: &$authenticatedIdentities)
|
.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,
|
service: identityService,
|
||||||
environment: environment)
|
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 {
|
private extension CompositionView {
|
||||||
func initialSetup() {
|
func initialSetup() {
|
||||||
addSubview(avatarImageView)
|
addSubview(avatarImageView)
|
||||||
|
@ -57,6 +63,9 @@ private extension CompositionView {
|
||||||
textView.adjustsFontForContentSizeCategory = true
|
textView.adjustsFontForContentSizeCategory = true
|
||||||
textView.font = .preferredFont(forTextStyle: .body)
|
textView.font = .preferredFont(forTextStyle: .body)
|
||||||
textView.textContainer.lineFragmentPadding = 0
|
textView.textContainer.lineFragmentPadding = 0
|
||||||
|
textView.inputAccessoryView = CompositionInputAccessoryView(viewModel: compositionConfiguration.viewModel)
|
||||||
|
textView.inputAccessoryView?.sizeToFit()
|
||||||
|
textView.delegate = self
|
||||||
|
|
||||||
let constraints = [
|
let constraints = [
|
||||||
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),
|
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),
|
||||||
|
|
Loading…
Reference in a new issue