Implement uploading photos.

This commit is contained in:
Marcin Czachursk 2023-02-17 12:21:09 +01:00
parent f80a5c3df1
commit 15452cc11f
10 changed files with 265 additions and 90 deletions

View File

@ -0,0 +1,90 @@
//
// https://mczachurski.dev
// Copyright © 2022 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
/// Represents a file or media attachment that can be added to a status.
public class UploadedAttachment: Codable {
public enum UploadedAttachmentType: String, Codable {
case unknown = "unknown"
case image = "image"
case gifv = "gifv"
case video = "video"
case audio = "audio"
}
/// The ID of the attachment in the database.
public let id: String
/// The type of the attachment.
public let type: UploadedAttachmentType
/// The location of the original full-size attachment.
public let url: URL?
/// The location of a scaled-down preview of the attachment.
public let previewUrl: URL?
/// The location of the full-size original attachment on the remote website.
public let remoteUrl: URL?
/// Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.
public let description: String?
/// A hash computed by the [BlurHash](https://github.com/woltapp/blurhash) algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.
public let blurhash: String?
private enum CodingKeys: String, CodingKey {
case id
case type
case url
case previewUrl = "preview_url"
case remoteUrl = "remote_url"
case description
case blurhash
}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(EntityId.self, forKey: .id)
self.type = try container.decode(UploadedAttachmentType.self, forKey: .type)
self.url = try? container.decode(URL.self, forKey: .url)
self.previewUrl = try? container.decode(URL.self, forKey: .previewUrl)
self.remoteUrl = try? container.decode(URL.self, forKey: .remoteUrl)
self.description = try? container.decode(String.self, forKey: .description)
self.blurhash = try? container.decode(String.self, forKey: .blurhash)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(type, forKey: .type)
if let url {
try container.encode(url, forKey: .url)
}
if let previewUrl {
try container.encode(previewUrl, forKey: .previewUrl)
}
if let remoteUrl {
try container.encode(remoteUrl, forKey: .remoteUrl)
}
if let description {
try container.encode(description, forKey: .description)
}
if let blurhash {
try container.encode(blurhash, forKey: .blurhash)
}
}
}

View File

@ -0,0 +1,15 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
extension NSMutableData {
func append(_ string: String) {
if let data = string.data(using: .utf8) {
self.append(data)
}
}
}

View File

@ -0,0 +1,18 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
public extension MastodonClientAuthenticated {
func upload(data: Data, fileName: String, mimeType: String, description: String?, focus: CGPoint?) async throws -> UploadedAttachment {
let request = try Self.request(
for: baseURL,
target: Mastodon.Media.upload(data, fileName, mimeType, description, focus),
withBearerToken: token)
return try await downloadJson(UploadedAttachment.self, request: request)
}
}

View File

@ -35,7 +35,7 @@ public extension MastodonClientProtocol {
request.httpMethod = target.method.rawValue
request.httpBody = target.httpBody
return request
}
}

View File

@ -6,66 +6,38 @@
import Foundation
extension Data {
func getMultipartFormDataBuilder(withBoundary boundary: String) -> MultipartFormDataBuilder? {
return MultipartFormDataBuilder(data: self, boundary: boundary)
struct MultipartFormData {
private let boundary: String
private var httpBody = NSMutableData()
private let separator: String = "\r\n"
init(boundary: String) {
self.boundary = boundary
}
func addTextField(named name: String, value: String) {
httpBody.append("--\(boundary)\(separator)")
httpBody.append(disposition(name) + separator)
httpBody.append("Content-Type: text/plain; charset=UTF-8" + separator + separator)
httpBody.append(value)
httpBody.append(separator)
}
func addDataField(named name: String, fileName: String, data: Data, mimeType: String) {
httpBody.append("--\(boundary)\(separator)")
httpBody.append(disposition(name) + "; filename=\"\(fileName)\"" + separator)
httpBody.append("Content-Type: \(mimeType)" + separator + separator)
httpBody.append(data)
httpBody.append(separator)
}
struct MultipartFormDataBuilder {
private let boundary: String
private var httpBody = NSMutableData()
private func disposition(_ name: String) -> String {
"Content-Disposition: form-data; name=\"\(name)\""
}
private let data: Data
fileprivate init(data: Data, boundary: String) {
self.data = data
self.boundary = boundary
}
func addTextField(named name: String, value: String) -> Self {
httpBody.append(textFormField(named: name, value: value))
return self
}
private func textFormField(named name: String, value: String) -> String {
var fieldString = "--\(boundary)\r\n"
fieldString += "Content-Disposition: form-data; name=\"\(name)\"\r\n"
fieldString += "Content-Type: text/plain; charset=UTF-8\r\n"
fieldString += "\r\n"
fieldString += "\(value)\r\n"
return fieldString
}
func addDataField(named name: String, data: Data, mimeType: String) -> Self {
httpBody.append(dataFormField(named: name, data: data, mimeType: mimeType))
return self
}
private func dataFormField(named name: String, data: Data, mimeType: String) -> Data {
let fieldData = NSMutableData()
fieldData.append("--\(boundary)\r\n")
fieldData.append("Content-Disposition: form-data; name=\"\(name)\"\r\n")
fieldData.append("Content-Type: \(mimeType)\r\n")
fieldData.append("\r\n")
fieldData.append(data)
fieldData.append("\r\n")
return fieldData as Data
}
func build() -> Data {
return httpBody as Data
}
func build() -> Data {
httpBody.append("--\(boundary)\(separator)")
return httpBody as Data
}
}
extension NSMutableData {
func append(_ string: String) {
if let data = string.data(using: .utf8) {
self.append(data)
}
}
}

View File

@ -10,12 +10,12 @@ fileprivate let multipartBoundary = UUID().uuidString
extension Mastodon {
public enum Media {
case upload(Data, String)
case upload(Data, String, String, String?, CGPoint?)
}
}
extension Mastodon.Media: TargetType {
fileprivate var apiPath: String { return "/api/v1/media" }
fileprivate var apiPath: String { return "/api/v2/media" }
/// The path to be appended to `baseURL` to form the full `URL`.
public var path: String {
@ -50,10 +50,19 @@ extension Mastodon.Media: TargetType {
public var httpBody: Data? {
switch self {
case .upload(let data, let mimeType):
return data.getMultipartFormDataBuilder(withBoundary: multipartBoundary)?
.addDataField(named: "file", data: data, mimeType: mimeType)
.build()
case .upload(let data, let fileName, let mimeType, let description, let focus):
let formDataBuilder = MultipartFormData(boundary: multipartBoundary)
formDataBuilder.addDataField(named: "file", fileName: fileName, data: data, mimeType: mimeType)
if let description {
formDataBuilder.addTextField(named: "description", value: description)
}
if let focus {
formDataBuilder.addTextField(named: "focus", value: "(\(focus.x), \(focus.y)")
}
return formDataBuilder.build()
}
}
}

View File

@ -139,6 +139,7 @@
F8C5E55F2988E92600ADF6A7 /* AccountModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C5E55E2988E92600ADF6A7 /* AccountModel.swift */; };
F8C5E56229892CC300ADF6A7 /* FirstAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C5E56129892CC300ADF6A7 /* FirstAppear.swift */; };
F8CC95CE2970761D00C9C2AC /* TintColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CC95CD2970761D00C9C2AC /* TintColor.swift */; };
F8FA9917299F7DBD007AB130 /* Client+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8FA9916299F7DBD007AB130 /* Client+Media.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -272,6 +273,7 @@
F8C5E56129892CC300ADF6A7 /* FirstAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstAppear.swift; sourceTree = "<group>"; };
F8C937A929882CA90004D782 /* Vernissage-001.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Vernissage-001.xcdatamodel"; sourceTree = "<group>"; };
F8CC95CD2970761D00C9C2AC /* TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TintColor.swift; sourceTree = "<group>"; };
F8FA9916299F7DBD007AB130 /* Client+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Client+Media.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -588,6 +590,7 @@
F8B9B34A298D4ACE009CC69C /* Client+Tags.swift */,
F8B9B34C298D4AE4009CC69C /* Client+Notifications.swift */,
F8B9B34E298D4B14009CC69C /* Client+Statuses.swift */,
F8FA9916299F7DBD007AB130 /* Client+Media.swift */,
F8B9B350298D4B34009CC69C /* Client+Account.swift */,
F8B9B352298D4B5D009CC69C /* Client+Search.swift */,
F8B9B355298D4C1E009CC69C /* Client+Instance.swift */,
@ -812,6 +815,7 @@
F85D4973296406E700751DF7 /* BottomRight.swift in Sources */,
F898DE702972868A004B4A6A /* String+Empty.swift in Sources */,
F86B7218296C27C100EE59EC /* ActionButton.swift in Sources */,
F8FA9917299F7DBD007AB130 /* Client+Media.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -939,7 +943,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\"";
DEVELOPMENT_TEAM = B2U9FEKYP8;
ENABLE_PREVIEWS = YES;
@ -976,7 +980,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\"";
DEVELOPMENT_TEAM = B2U9FEKYP8;
ENABLE_PREVIEWS = YES;

View File

@ -0,0 +1,17 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
import MastodonKit
/// Mastodon 'Statuses'.
extension Client {
public class Media: BaseClient {
func upload(data: Data, fileName: String, mimeType: String, description: String?, focus: CGPoint?) async throws -> UploadedAttachment? {
return try await mastodonClient.upload(data: data, fileName: fileName, mimeType: mimeType, description: description, focus: focus)
}
}
}

View File

@ -28,6 +28,7 @@ extension Client {
public var tags: Tags? { return Tags(mastodonClient: self.mastodonClient) }
public var notifications: Notifications? { return Notifications(mastodonClient: self.mastodonClient) }
public var statuses: Statuses? { return Statuses(mastodonClient: self.mastodonClient) }
public var media: Media? { return Media(mastodonClient: self.mastodonClient) }
public var accounts: Accounts? { return Accounts(mastodonClient: self.mastodonClient) }
public var search: Search? { return Search(mastodonClient: self.mastodonClient) }
public var instances: Instances { return Instances() }

View File

@ -20,10 +20,13 @@ struct ComposeView: View {
@State var statusViewModel: StatusModel?
@State private var text = String.empty()
@State private var publishDisabled = true
@State private var photosAreUploading = false
@State private var photosPickerVisible = false
@State private var selectedItems: [PhotosPickerItem] = []
@State private var photosData: [Data] = []
@State private var mediaAttachments: [UploadedAttachment] = []
@FocusState private var focusedField: FocusField?
@ -52,6 +55,9 @@ struct ComposeView: View {
.task {
self.focusedField = .content
}
.onChange(of: self.text) { newValue in
self.publishDisabled = self.isPublishButtonDisabled()
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
HStack(alignment: .center) {
@ -68,8 +74,6 @@ struct ComposeView: View {
}
}
HStack(alignment: .center) {
ForEach(self.photosData, id: \.self) { photoData in
if let uiImage = UIImage(data: photoData) {
@ -82,7 +86,6 @@ struct ComposeView: View {
}
.padding(8)
if let status = self.statusViewModel {
HStack (alignment: .top) {
UserAvatar(accountAvatar: status.account.avatar, size: .comment)
@ -115,13 +118,12 @@ struct ComposeView: View {
Task {
await self.publishStatus()
dismiss()
ToastrService.shared.showSuccess("Status published", imageSystemName: "message.fill")
}
} label: {
Text("Publish")
.foregroundColor(.white)
}
.disabled(self.text.isEmpty)
.disabled(self.publishDisabled)
.buttonStyle(.borderedProminent)
.tint(.accentColor)
}
@ -133,23 +135,8 @@ struct ComposeView: View {
}
}
.onChange(of: self.selectedItems) { selectedItem in
self.photosData = []
for item in self.selectedItems {
item.loadTransferable(type: Data.self) { result in
switch result {
case .success(let data):
if let data {
self.photosData.append(data)
} else {
ToastrService.shared.showError(subtitle: "Cannot show image preview.")
}
case .failure(let error):
ErrorService.shared.handle(error, message: "Cannot retreive image from library.", showToastr: true)
}
}
self.focusedField = .content
Task {
await self.loadPhotos()
}
}
.photosPicker(isPresented: $photosPickerVisible, selection: $selectedItems, maxSelectionCount: 4, matching: .images)
@ -157,9 +144,71 @@ struct ComposeView: View {
}
}
private func isPublishButtonDisabled() -> Bool {
// Publish always disabled when there is not status text.
if self.text.isEmpty {
return true
}
// When application is during uploading photos we cannot send new status.
if self.photosAreUploading == true {
return true
}
// When status is not a comment, then photo is required.
if self.statusViewModel == nil && self.mediaAttachments.isEmpty {
return true
}
return false
}
private func loadPhotos() async {
do {
self.photosAreUploading = true
self.photosData = []
self.mediaAttachments = []
self.publishDisabled = self.isPublishButtonDisabled()
for item in self.selectedItems {
if let data = try await item.loadTransferable(type: Data.self) {
self.photosData.append(data)
}
}
self.focusedField = .content
await self.upload()
self.photosAreUploading = false
self.publishDisabled = self.isPublishButtonDisabled()
} catch {
ErrorService.shared.handle(error, message: "Cannot retreive image from library.", showToastr: true)
}
}
private func upload() async {
for (index, photoData) in self.photosData.enumerated() {
do {
if let mediaAttachment = try await self.client.media?.upload(data: photoData,
fileName: "file-\(index).jpg",
mimeType: "image/jpeg",
description: nil,
focus: nil) {
self.mediaAttachments.append(mediaAttachment)
}
} catch {
ErrorService.shared.handle(error, message: "Error during post photo.", showToastr: true)
}
}
}
private func publishStatus() async {
do {
if let newStatus = try await self.client.statuses?.new(status:Mastodon.Statuses.Components(inReplyToId: self.statusViewModel?.id, text: self.text)) {
if let newStatus = try await self.client.statuses?.new(status: Mastodon.Statuses.Components(inReplyToId: self.statusViewModel?.id,
text: self.text,
mediaIds: self.mediaAttachments.map({ $0.id }))) {
ToastrService.shared.showSuccess("Status published", imageSystemName: "message.fill")
let statusModel = StatusModel(status: newStatus)
let commentModel = CommentModel(status: statusModel, showDivider: false)
self.applicationState.newComment = commentModel