Skip to content

Commit

Permalink
Communication: Support File Sharing (#260)
Browse files Browse the repository at this point in the history
  • Loading branch information
eylulnc authored Dec 14, 2024
1 parent a8173fb commit dbd81d2
Show file tree
Hide file tree
Showing 14 changed files with 416 additions and 127 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"code" = "Code";
"link" = "Link";
"uploadImage" = "Upload Image";
"uploadFile" = "Upload File";

// MARK: SendMessageMentionContentView
"members" = "Members";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ protocol MessagesService {
func sendAnswerMessage(for courseId: Int, message: Message, content: String) async -> NetworkResponse

/**
* Perform a post request for uploading a jpeg image in a specific conversation to the server.
*/
func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState<String>
* Perform a post request for uploading a file in a specific conversation to the server.
*/
func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState<String>

/**
* Perform a delete request for a message in a specific course to the server.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,30 +233,32 @@ struct MessagesServiceImpl: MessagesService {
}
}

struct UploadImageResult: Codable {
struct UploadFileResult: Codable {
let path: String
}

func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState<String> {
if image.count > 5 * 1024 * 1024 {
return .failure(error: .init(title: "File too big to upload"))
}

let request = MultipartFormDataRequest(path: "api/files/courses/\(courseId)/conversations/\(conversationId)")
request.addDataField(named: "file",
filename: "\(UUID().uuidString).jpg",
data: image,
mimeType: "image/jpeg")

let result: Swift.Result<(UploadImageResult, Int), APIClientError> = await client.sendRequest(request)

switch result {
case .success(let response):
return .done(response: response.0.path)
case .failure(let failure):
return .failure(error: .init(error: failure))
}
}
func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState<String> {
// Check file size limit
let maxFileSize = 5 * 1024 * 1024
if file.count > maxFileSize {
return .failure(error: .init(title: "File too big to upload"))
}

let request = MultipartFormDataRequest(path: "api/files/courses/\(courseId)/conversations/\(conversationId)")
request.addDataField(named: "file",
filename: filename,
data: file,
mimeType: mimeType)

let result: Swift.Result<(UploadFileResult, Int), APIClientError> = await client.sendRequest(request)

switch result {
case .success(let response):
return .done(response: response.0.path)
case .failure(let failure):
return .failure(error: .init(error: failure))
}
}

struct DeleteMessageRequest: APIRequest {
typealias Response = RawResponse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ extension MessagesServiceStub: MessagesService {
func unarchiveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse {
.loading
}

func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState<String> {
func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState<String> {
.loading
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//
// SendMessageUploadFileViewModel.swift
// ArtemisKit
//
// Created by Eylul Naz Can on 9.12.2024.
//

import Foundation
import UniformTypeIdentifiers
import Common
import SwiftUI

@Observable
final class SendMessageUploadFileViewModel: UploadViewModel {

var fileData: Data?
var fileName: String?
var isFilePickerPresented = false

private let messagesService: MessagesService

static let allowedFileTypes: [UTType] = [
.png,
.jpeg,
.gif,
.svg,
.pdf,
.rtf,
.plainText,
.json,
.spreadsheet,
.presentation,
UTType(filenameExtension: "doc") ?? .data,
UTType(filenameExtension: "docx") ?? .data,
UTType(filenameExtension: "xls") ?? .spreadsheet,
UTType(filenameExtension: "xlsx") ?? .spreadsheet,
UTType(filenameExtension: "ppt") ?? .presentation,
UTType(filenameExtension: "pptx") ?? .presentation
]

init(
courseId: Int,
conversationId: Int64,
messagesService: MessagesService = MessagesServiceFactory.shared
) {
self.messagesService = messagesService
super.init(courseId: courseId, conversationId: conversationId)
}

/// Handles changes to the selected file URL
func filePicked(at url: URL) {
isFilePickerPresented = false
loadFileData(from: url)
}

/// Reads the file data from the provided URL
private func loadFileData(from url: URL) {
uploadState = .compressing
filePath = nil

do {
guard url.startAccessingSecurityScopedResource() else {
throw NSFileProviderError(.notAuthenticated)
}
let fileData = try Data(contentsOf: url)
url.stopAccessingSecurityScopedResource()
let fileName = url.lastPathComponent
handleFileSelection(fileData: fileData, fileName: fileName)
} catch {
uploadState = .failed(error: .init(title: "Failed to read the selected file. Please try again."))
}
}

/// Validates and handles the selected file data
private func handleFileSelection(fileData: Data, fileName: String) {
self.fileData = fileData
self.fileName = fileName

if fileData.count > 5 * 1024 * 1024 {
uploadState = .failed(
error: .init(title: "The file size exceeds the 5MB limit. Please choose a smaller file.")
)
return
}

let fileExtension = (fileName as NSString).pathExtension.lowercased()
guard Self.allowedFileTypes.contains(where: { $0.preferredFilenameExtension == fileExtension }) else {
uploadState = .failed(
error: .init(title: "The file type '\(fileExtension)' is not supported.")
)
return
}

upload(data: fileData, fileName: fileName, mimeType: fileExtension)
}

private func upload(data: Data, fileName: String, mimeType: String) {
uploadState = .uploading

uploadTask = Task {
let result = await messagesService.uploadFile(for: courseId, and: conversationId, file: data, filename: fileName, mimeType: mimeType)
if Task.isCancelled { return }

switch result {
case .loading:
break
case .failure(let error):
uploadState = .failed(error: error)
case .done(let response):
filePath = response
uploadState = .done
}
}
}

func resetFileSelection() {
fileData = nil
fileName = nil
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// File.swift
// SendMessageUploadImageViewModel.swift
// ArtemisKit
//
// Created by Anian Schleyer on 09.11.24.
Expand All @@ -10,58 +10,11 @@ import Foundation
import PhotosUI
import SwiftUI

enum UploadState: Equatable {
case selectImage
case compressing
case uploading
case done
case failed(error: UserFacingError)
}

@Observable
final class SendMessageUploadImageViewModel {

let courseId: Int
let conversationId: Int64
final class SendMessageUploadImageViewModel: UploadViewModel {

var selection: PhotosPickerItem?
var image: UIImage?
var uploadState = UploadState.selectImage
var imagePath: String?
private var uploadTask: Task<(), Never>?

var showUploadScreen: Binding<Bool> {
.init {
self.uploadState != .selectImage
} set: { newValue in
if !newValue {
self.uploadState = .selectImage
}
}
}
var error: UserFacingError? {
switch uploadState {
case .failed(let error):
return error
default:
return nil
}
}

var statusLabel: String {
switch uploadState {
case .selectImage:
""
case .compressing:
R.string.localizable.loading()
case .uploading:
R.string.localizable.uploading()
case .done:
R.string.localizable.done()
case .failed(let error):
error.localizedDescription
}
}

private let messagesService: MessagesService

Expand All @@ -70,9 +23,8 @@ final class SendMessageUploadImageViewModel {
conversationId: Int64,
messagesService: MessagesService = MessagesServiceFactory.shared
) {
self.courseId = courseId
self.conversationId = conversationId
self.messagesService = messagesService
super.init(courseId: courseId, conversationId: conversationId)
}

/// Register as change handler for selection on View
Expand All @@ -86,7 +38,7 @@ final class SendMessageUploadImageViewModel {
}

uploadState = .compressing
imagePath = nil
filePath = nil

Task {
if let transferable = try? await item.loadTransferable(type: Data.self) {
Expand All @@ -107,7 +59,7 @@ final class SendMessageUploadImageViewModel {
uploadState = .uploading

uploadTask = Task {
let result = await messagesService.uploadImage(for: courseId, and: conversationId, image: imageData)
let result = await messagesService.uploadFile(for: courseId, and: conversationId, file: imageData, filename: "\(UUID().uuidString).jpg", mimeType: "image/jpeg")
if Task.isCancelled {
return
}
Expand All @@ -118,7 +70,7 @@ final class SendMessageUploadImageViewModel {
case .failure(let error):
uploadState = .failed(error: error)
case .done(let response):
imagePath = response
filePath = response
uploadState = .done
}
selection = nil
Expand All @@ -141,12 +93,4 @@ final class SendMessageUploadImageViewModel {
return imageData
}
}

func cancel() {
uploadTask?.cancel()
uploadTask = nil
selection = nil
image = nil
uploadState = .selectImage
}
}
Loading

0 comments on commit dbd81d2

Please sign in to comment.