Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Image uploading #652

Merged
merged 21 commits into from
Oct 1, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Mlem.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
03E0B9C82A61F0F400FED265 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */; };
03E0B9CA2A62B4A400FED265 /* ContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C92A62B4A400FED265 /* ContributorsView.swift */; };
03E0B9CC2A62CD5800FED265 /* ThemeSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9CB2A62CD5800FED265 /* ThemeSettingsView.swift */; };
03EA79C42AC0D92C00BCDC91 /* PostEditorDetailView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EA79C32AC0D92C00BCDC91 /* PostEditorDetailView+Logic.swift */; };
03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */; };
500C168E2A66FAAB006F243B /* HapticManager+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500C168D2A66FAAB006F243B /* HapticManager+Dependency.swift */; };
5016A2B12A67EB8600B257E8 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5016A2B02A67EB8600B257E8 /* UIViewController.swift */; };
5016A2B32A67EC0700B257E8 /* NotificationDisplayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5016A2B22A67EC0700B257E8 /* NotificationDisplayer.swift */; };
Expand Down Expand Up @@ -483,6 +485,8 @@
03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = "<group>"; };
03E0B9C92A62B4A400FED265 /* ContributorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorsView.swift; sourceTree = "<group>"; };
03E0B9CB2A62CD5800FED265 /* ThemeSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSettingsView.swift; sourceTree = "<group>"; };
03EA79C32AC0D92C00BCDC91 /* PostEditorDetailView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditorDetailView+Logic.swift"; sourceTree = "<group>"; };
03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Pictrs.swift"; sourceTree = "<group>"; };
500C168D2A66FAAB006F243B /* HapticManager+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HapticManager+Dependency.swift"; sourceTree = "<group>"; };
5016A2B02A67EB8600B257E8 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = "<group>"; };
5016A2B22A67EC0700B257E8 /* NotificationDisplayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDisplayer.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1872,6 +1876,7 @@
ADF266942A4E8A1E00EBA648 /* PostComposerView.swift */,
CD391F952A535F5400E213B5 /* ResponseEditorView.swift */,
03CB329D2A6D8E910021EF27 /* PostDetailEditorView.swift */,
03EA79C32AC0D92C00BCDC91 /* PostEditorDetailView+Logic.swift */,
);
path = Composer;
sourceTree = "<group>";
Expand Down Expand Up @@ -1903,6 +1908,7 @@
637218012A3A2AAD008C4816 /* APIClient.swift */,
50A881272A71D66B003E3661 /* APIClient+Community.swift */,
50A8812D2A72D76C003E3661 /* APIClient+Comment.swift */,
03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */,
CDEBC3382A9ADE6C00518D9D /* APIClient+Post.swift */,
);
path = APIClient;
Expand Down Expand Up @@ -2553,6 +2559,7 @@
CDB0117F2A6F70A000D043EB /* Editor Tracker.swift in Sources */,
6354F30A2A2E20040074C08D /* Alert - Multiple Alerts.swift in Sources */,
6318EDC727EE4E1500BFCAE8 /* Post.swift in Sources */,
03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */,
6372186C2A3A2AAD008C4816 /* SaveComment.swift in Sources */,
E4D4DBA22A7F233200C4F3DE /* FancyTabNavigationSelectionHashValueEnvironmentKey.swift in Sources */,
6DD8677A2A5083A200BEB00F /* Community Sidebar Link.swift in Sources */,
Expand Down Expand Up @@ -2638,6 +2645,7 @@
B1955A1F2A606F010056CF99 /* EasterFlagsTracker.swift in Sources */,
63D24ED92A169A5F005CCA81 /* UIApplication.swift in Sources */,
039439912A98FA6100463032 /* UserFeedView.swift in Sources */,
03EA79C42AC0D92C00BCDC91 /* PostEditorDetailView+Logic.swift in Sources */,
637218482A3A2AAD008C4816 /* APICommentReply.swift in Sources */,
032109472AA7C3FC00912DFC /* CommunityLabelView.swift in Sources */,
637218502A3A2AAD008C4816 /* APIPersonAggregates.swift in Sources */,
Expand Down
100 changes: 100 additions & 0 deletions Mlem/API/APIClient/APIClient+Pictrs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//
// APIClient+Pictrs.swift
// Mlem
//
// Created by Sjmarf on 24/09/2023.
//

import Foundation

extension APIClient {
func uploadImage(_ imageData: Data, callback: @escaping (_ progress: Double) -> Void) async throws -> ImageUploadResponse {

let delegate = ImageUploadDelegate(callback: callback)

// Modify the instance URL to remove "api/v3" and add "pictrs/image".
var components = URLComponents()
components.scheme = try self.session.instanceUrl.scheme
components.host = try self.session.instanceUrl.host
components.path = "/pictrs/image"

guard let url = components.url else {
throw APIClientError.response(.init(error: "Failed to modify instance URL to add pictrs."), nil)
}
var request = URLRequest(url: url)
Sjmarf marked this conversation as resolved.
Show resolved Hide resolved
request.httpMethod = "POST"

let boundary = UUID().uuidString

request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("jwt=\(try session.token)", forHTTPHeaderField: "Cookie")

let multiPartForm: MultiPartForm = try .init(
mimeType: "image/png",
fileName: "image.png",
imageData: imageData,
auth: session.token
)

let (data, _) = try await self.urlSession.upload(
for: request,
from: multiPartForm.createField(boundary: boundary),
delegate: delegate
)

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
return try decoder.decode(ImageUploadResponse.self, from: data)
} catch {
throw APIClientError.decoding(data)
}
}
}

struct ImageUploadResponse: Codable {
Sjmarf marked this conversation as resolved.
Show resolved Hide resolved
public let msg: String
public let files: [File]

struct File: Codable, Equatable {
public let file: String
public let deleteToken: String
}
}

private struct MultiPartForm: Codable {
var mimeType: String
var fileName: String
var imageData: Data
var auth: String

func createField(boundary: String) -> Data {
var data = Data()
data.append(Data("--\(boundary)\r\n".utf8))
data.append(Data("Content-Disposition: form-data; name=\"images[]\"; filename=\"\(fileName)\"\r\n".utf8))
data.append(Data("Content-Type: \(mimeType)\r\n".utf8))
data.append(Data("\r\n".utf8))
data.append(imageData)
data.append(Data("\r\n".utf8))
data.append(Data("--\(boundary)--\r\n".utf8))
return data
}
}

private class ImageUploadDelegate: NSObject, URLSessionTaskDelegate {
public let callback: (Double) -> Void

public init(callback: @escaping (Double) -> Void) {
self.callback = callback
}

public func urlSession(
_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64
) {
callback(Double(totalBytesSent) / Double(totalBytesExpectedToSend))
}
}
182 changes: 113 additions & 69 deletions Mlem/Views/Shared/Composer/PostDetailEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Dependencies
import SwiftUI
import PhotosUI

extension HorizontalAlignment {
enum LabelStart: AlignmentID {
Expand All @@ -23,6 +24,14 @@ struct PostDetailEditorView: View {
case title, url, body
}

enum ImageUploadProgress {
case noImage
case uploading(Double)
case uploaded
case failed(Error)
}

@Dependency(\.apiClient) var apiClient
@Dependency(\.errorHandler) var errorHandler

@Environment(\.dismiss) var dismiss
Expand All @@ -39,6 +48,11 @@ struct PostDetailEditorView: View {
@State var isShowingErrorDialog: Bool = false
@State var errorDialogMessage: String = ""

@State var showingPhotosPicker: Bool = false
@State var imageSelection: PhotosPickerItem?
@State var uploadedImage: Image?
@State var uploadProgress: ImageUploadProgress = .noImage

@FocusState private var focusedField: Field?

init(
Expand All @@ -57,52 +71,6 @@ struct PostDetailEditorView: View {
self.onSubmit = onSubmit
}

private var isReadyToPost: Bool {
// This only requirement to post is a title
postTitle.trimmed.isNotEmpty
}

private var isValidURL: Bool {
guard postURL.lowercased().hasPrefix("http://") ||
postURL.lowercased().hasPrefix("https://") else {
return false // URL protocol is missing
}

guard URL(string: postURL) != nil else {
return false // Not Parsable
}

return true
}

func submitPost() async {
do {
guard postTitle.trimmed.isNotEmpty else {
errorDialogMessage = "You need to enter a title for your post."
isShowingErrorDialog = true
return
}

guard postURL.lowercased().isEmpty || isValidURL else {
errorDialogMessage = "You seem to have entered an invalid URL, please check it again."
isShowingErrorDialog = true
return
}

isSubmitting = true

try await onSubmit()

} catch {
isSubmitting = false
errorHandler.handle(error)
}
}

func uploadImage() {
print("Uploading")
}

var body: some View {
ZStack {
VStack(spacing: 15) {
Expand Down Expand Up @@ -143,30 +111,37 @@ struct PostDetailEditorView: View {
focusedField = .title
}
}
}

// URL Row
HStack {
Text("URL")
.foregroundColor(.secondary)
.dynamicTypeSize(.small ... .accessibility2)
.accessibilityHidden(true)

TextField("Your post link (Optional)", text: $postURL)
.alignmentGuide(.labelStart) { $0[HorizontalAlignment.leading] }
.dynamicTypeSize(.small ... .accessibility2)
.keyboardType(.URL)
.autocorrectionDisabled()
.autocapitalization(.none)
.accessibilityLabel("URL")
.focused($focusedField, equals: .url)

// Upload button, temporarily hidden
// Button(action: uploadImage) {
// Image(systemName: "paperclip")
// .font(.title3)
// .dynamicTypeSize(.medium)
// }
// .accessibilityLabel("Upload Image")
// URL Row
if imageSelection != nil {
imageWidget
} else {
VStack(alignment: .labelStart) {
HStack {
Text("URL")
.foregroundColor(.secondary)
.dynamicTypeSize(.small ... .accessibility2)
.accessibilityHidden(true)

TextField("Your post link (Optional)", text: $postURL)
.alignmentGuide(.labelStart) { $0[HorizontalAlignment.leading] }
.dynamicTypeSize(.small ... .accessibility2)
.keyboardType(.URL)
.autocorrectionDisabled()
.autocapitalization(.none)
.accessibilityLabel("URL")
.focused($focusedField, equals: .url)

Button {
showingPhotosPicker = true
} label: {
Image(systemName: "paperclip")
.font(.title3)
.dynamicTypeSize(.medium)
}
.accessibilityLabel("Upload Image")
}
}
}

Expand Down Expand Up @@ -224,5 +199,74 @@ struct PostDetailEditorView: View {
}
.navigationBarColor()
.navigationBarTitleDisplayMode(.inline)
.photosPicker(isPresented: $showingPhotosPicker, selection: $imageSelection, matching: .images)
.onChange(of: imageSelection) { newValue in
if newValue != nil {
uploadImage()
Sjmarf marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

@ViewBuilder
var imageWidget: some View {
VStack {
HStack(spacing: 10) {
Sjmarf marked this conversation as resolved.
Show resolved Hide resolved
if let uploadedImage = uploadedImage {
uploadedImage
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60, alignment: .center)
.clipShape(RoundedRectangle(cornerRadius: 5))
.clipped()
Sjmarf marked this conversation as resolved.
Show resolved Hide resolved
} else {
RoundedRectangle(cornerRadius: 5)
.fill(.secondary)
.frame(width: 60, height: 60)
Sjmarf marked this conversation as resolved.
Show resolved Hide resolved
}
VStack(alignment: .leading) {
Text("Attached image")
Spacer()
HStack {
switch uploadProgress {
case .uploading(let progress):
Text("Uploading...")
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle())
.frame(width: 100, height: 10)
case .uploaded:
Text("Uploaded")
case .failed:
Text("Failed")
.foregroundStyle(.red)
default:
EmptyView()
}
}
.foregroundStyle(.secondary)
}
.frame(height: 40)
Spacer()
}
.padding(10)
}
.frame(maxWidth: .infinity)
.background {
RoundedRectangle(cornerRadius: 10)
Sjmarf marked this conversation as resolved.
Show resolved Hide resolved
.fill(Color(UIColor.secondarySystemBackground))
}
.overlay(alignment: .topTrailing) {
Button {
imageSelection = nil
uploadedImage = nil
postURL = ""
} label: {
Image(systemName: "multiply")
Sjmarf marked this conversation as resolved.
Show resolved Hide resolved
.fontWeight(.semibold)
.tint(.secondary)
.padding(5)
.background(Circle().fill(.background))
}
.padding(5)
}
}
}
Loading