Implement uploading photos.
This commit is contained in:
parent
f80a5c3df1
commit
15452cc11f
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -6,66 +6,38 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
|
||||
func getMultipartFormDataBuilder(withBoundary boundary: String) -> MultipartFormDataBuilder? {
|
||||
return MultipartFormDataBuilder(data: self, boundary: boundary)
|
||||
}
|
||||
|
||||
struct MultipartFormDataBuilder {
|
||||
struct MultipartFormData {
|
||||
private let boundary: String
|
||||
private var httpBody = NSMutableData()
|
||||
|
||||
private let data: Data
|
||||
private let separator: String = "\r\n"
|
||||
|
||||
fileprivate init(data: Data, boundary: String) {
|
||||
self.data = data
|
||||
init(boundary: String) {
|
||||
self.boundary = boundary
|
||||
}
|
||||
|
||||
func addTextField(named name: String, value: String) -> Self {
|
||||
httpBody.append(textFormField(named: name, value: value))
|
||||
return self
|
||||
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)
|
||||
}
|
||||
|
||||
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, 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)
|
||||
}
|
||||
|
||||
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
|
||||
private func disposition(_ name: String) -> String {
|
||||
"Content-Disposition: form-data; name=\"\(name)\""
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() }
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue