From a8173fb475877cc097847d64073301c2b57afba8 Mon Sep 17 00:00:00 2001
From: Anian Schleyer <98647423+anian03@users.noreply.github.com>
Date: Sat, 7 Dec 2024 19:00:31 +0100
Subject: [PATCH 1/4] `Communication`: Support adding mentions for FAQs in
messages (#251)
* Add support for mentioning FAQs
* Increment version number
---
Artemis/Supporting/Info.plist | 2 +-
.../Faq/Services/FaqService/FaqService.swift | 6 +--
.../Resources/en.lproj/Localizable.strings | 2 +
.../SendMessageFAQPickerViewModel.swift | 46 +++++++++++++++++++
.../SendMessageFAQPicker.swift | 43 +++++++++++++++++
.../SendMessageMentionContentView.swift | 3 ++
.../SendMessageViews/SendMessageView.swift | 7 +++
7 files changed, 105 insertions(+), 4 deletions(-)
create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageFAQPickerViewModel.swift
create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageFAQPicker.swift
diff --git a/Artemis/Supporting/Info.plist b/Artemis/Supporting/Info.plist
index 69490c06..97e4eb76 100644
--- a/Artemis/Supporting/Info.plist
+++ b/Artemis/Supporting/Info.plist
@@ -21,7 +21,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 1.5.1
+ 1.6.0
CFBundleVersion
1
ITSAppUsesNonExemptEncryption
diff --git a/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift b/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift
index bf7abc0b..dac4c5fa 100644
--- a/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift
+++ b/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift
@@ -8,11 +8,11 @@
import Common
import SharedModels
-protocol FaqService {
+public protocol FaqService {
func getFaqs(for courseId: Int) async -> DataState<[FaqDTO]>
func getFaq(with faqId: Int64, for courseId: Int) async -> DataState
}
-enum FaqServiceFactory {
- static let shared: FaqService = FaqServiceImpl()
+public enum FaqServiceFactory {
+ public static let shared: FaqService = FaqServiceImpl()
}
diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings
index bfb47e67..714c27ba 100644
--- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings
+++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings
@@ -11,6 +11,8 @@
"lecture" = "Lecture";
"channelsUnavailable" = "No Channels";
"lecturesUnavailable" = "No Lectures";
+"faqs" = "FAQs";
+"faqsUnavailable" = "No FAQs";
"membersUnavailable" = "No Members";
"messageAction" = "Message %@";
"mentionSlideNumber" = "Slide %i";
diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageFAQPickerViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageFAQPickerViewModel.swift
new file mode 100644
index 00000000..65db68e1
--- /dev/null
+++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageFAQPickerViewModel.swift
@@ -0,0 +1,46 @@
+//
+// SendMessageFAQPickerViewModel.swift
+// ArtemisKit
+//
+// Created by Anian Schleyer on 04.12.24.
+//
+
+import Faq
+import SharedModels
+import SwiftUI
+
+@Observable
+final class SendMessageFAQPickerViewModel {
+
+ let course: Course
+ var faqs: [FaqDTO]
+
+ private let delegate: SendMessageMentionContentDelegate
+ private let faqService: FaqService
+
+ init(
+ course: Course,
+ faqs: [FaqDTO] = [],
+ delegate: SendMessageMentionContentDelegate = SendMessageMentionContentDelegate { _ in },
+ faqService: FaqService = FaqServiceFactory.shared
+ ) {
+ self.course = course
+ self.faqs = faqs
+ self.delegate = delegate
+ self.faqService = faqService
+ }
+
+ func loadFAQs() async {
+ let faqs = await faqService.getFaqs(for: course.id)
+
+ if case let .done(faqs) = faqs {
+ self.faqs = faqs.filter {
+ $0.faqState == .accepted
+ }
+ }
+ }
+
+ func select(faq: FaqDTO) {
+ delegate.pickerDidSelect("[faq]\(faq.questionTitle)(/courses/\(course.id)/faq?faqId=\(faq.id))[/faq]")
+ }
+}
diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageFAQPicker.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageFAQPicker.swift
new file mode 100644
index 00000000..447422ea
--- /dev/null
+++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageFAQPicker.swift
@@ -0,0 +1,43 @@
+//
+// SendMessageFAQPicker.swift
+// ArtemisKit
+//
+// Created by Anian Schleyer on 07.12.24.
+//
+
+import SharedModels
+import SwiftUI
+
+struct SendMessageFAQPicker: View {
+
+ @State var viewModel: SendMessageFAQPickerViewModel
+
+ var body: some View {
+ Group {
+ if !viewModel.faqs.isEmpty {
+ List(viewModel.faqs) { faq in
+ Button {
+ viewModel.select(faq: faq)
+ } label: {
+ Text(faq.questionTitle)
+ .lineLimit(2)
+ }
+ }
+ .listStyle(.plain)
+ } else {
+ ContentUnavailableView(R.string.localizable.faqsUnavailable(), systemImage: "magnifyingglass")
+ }
+ }
+ .task {
+ await viewModel.loadFAQs()
+ }
+ .navigationTitle(R.string.localizable.faqs())
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
+
+extension SendMessageFAQPicker {
+ init(course: Course, delegate: SendMessageMentionContentDelegate) {
+ self.init(viewModel: SendMessageFAQPickerViewModel(course: course, delegate: delegate))
+ }
+}
diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift
index 6a4766a8..2a96be2c 100644
--- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift
+++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift
@@ -33,6 +33,8 @@ struct SendMessageMentionContentView: View {
SendMessageExercisePicker(delegate: delegate, course: viewModel.course)
case .lecture:
SendMessageLecturePicker(course: viewModel.course, delegate: delegate)
+ case .faq:
+ SendMessageFAQPicker(course: viewModel.course, delegate: delegate)
}
}
.toolbar {
@@ -53,4 +55,5 @@ enum MessageMentionContentType: Identifiable {
case exercise
case lecture
+ case faq
}
diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift
index 83ae4e96..95eebe87 100644
--- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift
+++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift
@@ -141,6 +141,13 @@ private extension SendMessageView {
} label: {
Label(R.string.localizable.lectures(), systemImage: "character.book.closed")
}
+ if viewModel.course.faqEnabled == true {
+ Button {
+ viewModel.wantsToAddMessageMentionContentType = .faq
+ } label: {
+ Label(R.string.localizable.faqs(), systemImage: "questionmark.circle")
+ }
+ }
} label: {
Label(R.string.localizable.mention(), systemImage: "plus.circle.fill")
}
From dbd81d2f03e5329cd9b28a8d6ac6dde998352aa7 Mon Sep 17 00:00:00 2001
From: Eylul Naz Can <65118253+eylulnc@users.noreply.github.com>
Date: Sat, 14 Dec 2024 01:35:01 +0100
Subject: [PATCH 2/4] `Communication`: Support File Sharing (#260)
---
.../Resources/en.lproj/Localizable.strings | 1 +
.../MessagesService/MessagesService.swift | 6 +-
.../MessagesService/MessagesServiceImpl.swift | 44 ++++---
.../MessagesService/MessagesServiceStub.swift | 4 +-
.../SendMessageUploadFileViewModel.swift | 120 ++++++++++++++++++
.../SendMessageUploadImageViewModel.swift | 68 +---------
.../FileUpload/UploadViewModel.swift | 75 +++++++++++
.../SendMessageViewModel.swift | 4 +
.../MessagesAvailableView.swift | 3 +-
.../FileUpload/FilePickerViewModifier.swift | 91 +++++++++++++
.../SendMessageFilePickerView.swift | 32 +++++
.../SendMessageImagePickerView.swift | 41 ++++++
.../UploadFileView.swift} | 45 +------
.../SendMessageViews/SendMessageView.swift | 9 ++
14 files changed, 416 insertions(+), 127 deletions(-)
create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift
rename ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/{ => FileUpload}/SendMessageUploadImageViewModel.swift (57%)
create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift
create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift
create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift
create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift
rename ArtemisKit/Sources/Messages/Views/SendMessageViews/{SendMessageImagePickerView.swift => FileUpload/UploadFileView.swift} (60%)
diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings
index 714c27ba..d79222a4 100644
--- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings
+++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings
@@ -26,6 +26,7 @@
"code" = "Code";
"link" = "Link";
"uploadImage" = "Upload Image";
+"uploadFile" = "Upload File";
// MARK: SendMessageMentionContentView
"members" = "Members";
diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift
index c2ef0032..c25a896d 100644
--- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift
+++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift
@@ -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
+ * 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
/**
* Perform a delete request for a message in a specific course to the server.
diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift
index b953189d..cac82b7e 100644
--- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift
+++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift
@@ -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 {
- 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 {
+ // 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
diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift
index f1fb4d34..3676ae5a 100644
--- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift
+++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift
@@ -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 {
+
+ func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState {
.loading
}
}
diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift
new file mode 100644
index 00000000..863319e2
--- /dev/null
+++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift
@@ -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
+ }
+}
diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift
similarity index 57%
rename from ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift
rename to ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift
index a9d9e33f..bd535752 100644
--- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift
+++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift
@@ -1,5 +1,5 @@
//
-// File.swift
+// SendMessageUploadImageViewModel.swift
// ArtemisKit
//
// Created by Anian Schleyer on 09.11.24.
@@ -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 {
- .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
@@ -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
@@ -86,7 +38,7 @@ final class SendMessageUploadImageViewModel {
}
uploadState = .compressing
- imagePath = nil
+ filePath = nil
Task {
if let transferable = try? await item.loadTransferable(type: Data.self) {
@@ -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
}
@@ -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
@@ -141,12 +93,4 @@ final class SendMessageUploadImageViewModel {
return imageData
}
}
-
- func cancel() {
- uploadTask?.cancel()
- uploadTask = nil
- selection = nil
- image = nil
- uploadState = .selectImage
- }
}
diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift
new file mode 100644
index 00000000..0d32bd4e
--- /dev/null
+++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift
@@ -0,0 +1,75 @@
+//
+// UploadState.swift
+// ArtemisKit
+//
+// Created by Eylul Naz Can on 9.12.2024.
+//
+
+import Common
+import Foundation
+import SwiftUI
+
+enum UploadState: Equatable {
+ case idle
+ case compressing
+ case uploading
+ case done
+ case failed(error: UserFacingError)
+}
+
+@Observable
+class UploadViewModel {
+ var uploadState: UploadState = .idle
+ var filePath: String?
+
+ private(set) var courseId: Int
+ private(set) var conversationId: Int64
+
+ internal var uploadTask: Task<(), Never>?
+
+ init(courseId: Int, conversationId: Int64) {
+ self.courseId = courseId
+ self.conversationId = conversationId
+ }
+
+ var showUploadScreen: Binding {
+ .init {
+ self.uploadState != .idle
+ } set: { newValue in
+ if !newValue {
+ self.uploadState = .idle
+ }
+ }
+ }
+
+ var error: UserFacingError? {
+ switch uploadState {
+ case .failed(let error):
+ return error
+ default:
+ return nil
+ }
+ }
+
+ var statusLabel: String {
+ switch uploadState {
+ case .idle:
+ return ""
+ case .compressing:
+ return R.string.localizable.loading()
+ case .uploading:
+ return R.string.localizable.uploading()
+ case .done:
+ return R.string.localizable.done()
+ case .failed(let error):
+ return error.localizedDescription
+ }
+ }
+
+ func cancel() {
+ uploadTask?.cancel()
+ uploadTask = nil
+ uploadState = .idle
+ filePath = nil
+ }
+}
diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift
index f9361022..ac40a79e 100644
--- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift
+++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift
@@ -221,6 +221,10 @@ extension SendMessageViewModel {
func insertImageMention(path: String) {
appendToSelection(before: ")", placeholder: "image")
}
+
+ func insertFileMention(path: String, fileName: String) {
+ appendToSelection(before: "[", after: "](\(path))", placeholder: fileName)
+ }
/// Prepends/Appends the given snippets to text the user has selected.
private func appendToSelection(before: String, after: String, placeholder: String) {
diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift
index 1f4ec102..5b293c58 100644
--- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift
+++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift
@@ -193,7 +193,8 @@ public struct MessagesAvailableView: View {
}
}
}
- .toolbar(.hidden, for: .navigationBar)
+ // Add a file picker here, inside the navigation it doesn't work sometimes
+ .supportsFilePicker()
}
@ViewBuilder var filterBar: some View {
diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift
new file mode 100644
index 00000000..81817c6b
--- /dev/null
+++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift
@@ -0,0 +1,91 @@
+//
+// FilePickerViewModifier.swift
+// ArtemisKit
+//
+// Created by Anian Schleyer on 13.12.24.
+//
+
+import SwiftUI
+
+extension View {
+ /// Add this further up in the view hierarchy to use a file picker somewhere below
+ func supportsFilePicker() -> some View {
+ modifier(AddFilePickerViewModifier())
+ }
+
+ /// Use this to utitlize the previously added file picker
+ func filePicker(isPresented: Binding,
+ onFilePicked: @escaping (URL) -> Void,
+ onError: @escaping (Error) -> Void) -> some View {
+ modifier(UseFilePickerViewModifier(presentFilePicker: isPresented,
+ onFilePick: onFilePicked,
+ onError: onError))
+ }
+}
+
+/// Adds a fileImporter in the view hierarchy for use in lower levels.
+/// Use this before you can use `.filePicker` on a View.
+private struct AddFilePickerViewModifier: ViewModifier {
+ @State var manager = FilePickerManager()
+ func body(content: Content) -> some View {
+ @Bindable var manager = manager
+
+ content
+ .environment(\.filePickerManager, manager)
+ .fileImporter(isPresented: $manager.isPresented,
+ allowedContentTypes: SendMessageUploadFileViewModel.allowedFileTypes,
+ allowsMultipleSelection: false) { result in
+ switch result {
+ case .success(let success):
+ if let url = success.first {
+ manager.onPicked(url)
+ }
+ case .failure(let failure):
+ manager.onError(failure)
+ }
+ }
+ }
+}
+
+/// This uses the previously injected fileImporter.
+/// *Prerequesite*: Use `.supportsFilePicker()` on a view further up
+private struct UseFilePickerViewModifier: ViewModifier {
+ @Environment(\.filePickerManager) var manager
+
+ @Binding var presentFilePicker: Bool
+ let onFilePick: (URL) -> Void
+ let onError: (Error) -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .onChange(of: presentFilePicker, initial: true) { _, newValue in
+ manager.isPresented = newValue
+ }
+ .onAppear {
+ manager.onPicked = onFilePick
+ manager.onError = onError
+ }
+ }
+}
+
+@Observable
+class FilePickerManager {
+ var isPresented = false
+ var onPicked: (URL) -> Void = { _ in }
+ var onError: (Error) -> Void = { _ in }
+}
+
+private enum FilePickerManagerKey: EnvironmentKey {
+ static let defaultValue = FilePickerManager()
+}
+
+private extension EnvironmentValues {
+ var filePickerManager: FilePickerManager {
+ get {
+ self[FilePickerManagerKey.self]
+ }
+ set {
+ self[FilePickerManagerKey.self] = newValue
+ }
+ }
+}
diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift
new file mode 100644
index 00000000..508e03b4
--- /dev/null
+++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct SendMessageFilePickerView: View {
+ var sendViewModel: SendMessageViewModel
+ @Bindable private var viewModel: SendMessageUploadFileViewModel
+
+ init(sendViewModel: SendMessageViewModel, viewModel: SendMessageUploadFileViewModel) {
+ self.viewModel = viewModel
+ self.sendViewModel = sendViewModel
+ }
+
+ var body: some View {
+ Button {
+ viewModel.isFilePickerPresented = true
+ } label: {
+ Label("Upload File", systemImage: "doc.fill")
+ }
+ .filePicker(isPresented: $viewModel.isFilePickerPresented,
+ onFilePicked: viewModel.filePicked(at:)) { error in
+ viewModel.uploadState = .failed(error: .init(title: error.localizedDescription))
+ }
+ .sheet(isPresented: viewModel.showUploadScreen) {
+ if let path = viewModel.filePath {
+ sendViewModel.insertFileMention(path: path, fileName: viewModel.fileName ?? "file")
+ }
+ viewModel.resetFileSelection()
+ } content: {
+ UploadFileProgressView(viewModel: viewModel)
+ }
+ }
+}
diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift
new file mode 100644
index 00000000..a888a0af
--- /dev/null
+++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift
@@ -0,0 +1,41 @@
+//
+// SendMessageImagePickerView.swift
+// ArtemisKit
+//
+// Created by Anian Schleyer on 09.11.24.
+//
+
+import PhotosUI
+import SwiftUI
+
+struct SendMessageImagePickerView: View {
+
+ var sendViewModel: SendMessageViewModel
+ @State private var viewModel: SendMessageUploadImageViewModel
+
+ init(sendMessageViewModel: SendMessageViewModel) {
+ self._viewModel = State(initialValue: .init(courseId: sendMessageViewModel.course.id,
+ conversationId: sendMessageViewModel.conversation.id))
+ self.sendViewModel = sendMessageViewModel
+ }
+
+ var body: some View {
+ PhotosPicker(selection: $viewModel.selection,
+ matching: .images,
+ preferredItemEncoding: .compatible) {
+ Label(R.string.localizable.uploadImage(), systemImage: "photo.fill")
+ }
+ .onChange(of: viewModel.selection) {
+ viewModel.onChange()
+ }
+ .sheet(isPresented: viewModel.showUploadScreen) {
+ if let path = viewModel.filePath {
+ sendViewModel.insertImageMention(path: path)
+ }
+ viewModel.selection = nil
+ viewModel.image = nil
+ } content: {
+ UploadFileProgressView(viewModel: viewModel)
+ }
+ }
+}
diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift
similarity index 60%
rename from ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift
rename to ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift
index e6124dad..f65c4ad3 100644
--- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift
+++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift
@@ -1,47 +1,14 @@
//
-// SendMessageImagePickerView.swift
+// UploadFileView.swift
// ArtemisKit
//
-// Created by Anian Schleyer on 09.11.24.
+// Created by Anian Schleyer on 13.12.24.
//
-import PhotosUI
import SwiftUI
-struct SendMessageImagePickerView: View {
-
- var sendViewModel: SendMessageViewModel
- @State private var viewModel: SendMessageUploadImageViewModel
-
- init(sendMessageViewModel: SendMessageViewModel) {
- self._viewModel = State(initialValue: .init(courseId: sendMessageViewModel.course.id,
- conversationId: sendMessageViewModel.conversation.id))
- self.sendViewModel = sendMessageViewModel
- }
-
- var body: some View {
- PhotosPicker(selection: $viewModel.selection,
- matching: .images,
- preferredItemEncoding: .compatible) {
- Label(R.string.localizable.uploadImage(), systemImage: "photo.fill")
- }
- .onChange(of: viewModel.selection) {
- viewModel.onChange()
- }
- .sheet(isPresented: viewModel.showUploadScreen) {
- if let path = viewModel.imagePath {
- sendViewModel.insertImageMention(path: path)
- }
- viewModel.selection = nil
- viewModel.image = nil
- } content: {
- UploadImageView(viewModel: viewModel)
- }
- }
-}
-
-private struct UploadImageView: View {
- var viewModel: SendMessageUploadImageViewModel
+struct UploadFileProgressView: View {
+ var viewModel: UploadViewModel
@Environment(\.dismiss) var dismiss
var body: some View {
@@ -99,7 +66,8 @@ private struct UploadImageView: View {
}
@ViewBuilder var backgroundImage: some View {
- if let image = viewModel.image {
+ if let vm = viewModel as? SendMessageUploadImageViewModel,
+ let image = vm.image {
Image(uiImage: image)
.resizable()
.scaledToFill()
@@ -110,3 +78,4 @@ private struct UploadImageView: View {
}
}
}
+
diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift
index 95eebe87..2cd36a2f 100644
--- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift
+++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift
@@ -13,9 +13,17 @@ import SwiftUI
struct SendMessageView: View {
@State var viewModel: SendMessageViewModel
+ /// This has to be in here, otherwise it gets deinitialized while file picker is open,
+ /// due to the textfield losing focus and the toolbar disappearing
+ @State private var uploadFileViewModel: SendMessageUploadFileViewModel
@FocusState private var isFocused: Bool
+ init(viewModel: SendMessageViewModel) {
+ self._viewModel = State(initialValue: viewModel)
+ self._uploadFileViewModel = State(initialValue: .init(courseId: viewModel.course.id, conversationId: viewModel.conversation.id))
+ }
+
var body: some View {
VStack(spacing: 0) {
if viewModel.isEditing {
@@ -195,6 +203,7 @@ private extension SendMessageView {
Label(R.string.localizable.link(), systemImage: "link")
}
SendMessageImagePickerView(sendMessageViewModel: viewModel)
+ SendMessageFilePickerView(sendViewModel: viewModel, viewModel: uploadFileViewModel)
}
.labelStyle(.iconOnly)
.font(.title3)
From c6c3aaea962539ef213ea86669ba3cb1a8bbf91f Mon Sep 17 00:00:00 2001
From: Anian Schleyer <98647423+anian03@users.noreply.github.com>
Date: Sat, 14 Dec 2024 03:09:27 +0100
Subject: [PATCH 3/4] `Communication`: Fix certain reactions not showing (#261)
---
.../xcshareddata/swiftpm/Package.resolved | 17 ++++-------------
ArtemisKit/Package.swift | 1 +
2 files changed, 5 insertions(+), 13 deletions(-)
diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index b5075ef9..824ead16 100644
--- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "5c8626acffff005e2642c0e07e1f755598c39323b3bb020fb01711f64303781e",
+ "originHash" : "d98f8b8207dd2f669664cadc42e7d9f386e6aa80a1ad0c3e37c5b13e2081ecc9",
"pins" : [
{
"identity" : "apollon-ios-module",
@@ -10,15 +10,6 @@
"version" : "1.0.9"
}
},
- {
- "identity" : "artemis-ios-core-modules",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/ls1intum/artemis-ios-core-modules",
- "state" : {
- "revision" : "83a8eafae4d7b098e303aa0c06b392c505852b7f",
- "version" : "15.1.2"
- }
- },
{
"identity" : "collectionconcurrencykit",
"kind" : "remoteSourceControl",
@@ -85,10 +76,10 @@
{
"identity" : "smile",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/onmyway133/Smile",
+ "location" : "https://github.com/onmyway133/Smile.git",
"state" : {
- "revision" : "40604722a7a56f735124e069fcbb58307637744b",
- "version" : "2.1.0"
+ "branch" : "6bacbf7",
+ "revision" : "6bacbf74638eb725fb0dd3e728341bc17acf8958"
}
},
{
diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift
index 80a0c678..a7eea141 100644
--- a/ArtemisKit/Package.swift
+++ b/ArtemisKit/Package.swift
@@ -18,6 +18,7 @@ let package = Package(
])
],
dependencies: [
+ .package(url: "https://github.com/onmyway133/Smile.git", revision: "6bacbf7"),
.package(url: "https://github.com/Kelvas09/EmojiPicker.git", from: "1.0.0"),
.package(url: "https://github.com/ls1intum/apollon-ios-module", .upToNextMajor(from: "1.0.2")),
.package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "15.1.2")),
From 3de04a57f5092b7f74a5f51063cafde445adc7a0 Mon Sep 17 00:00:00 2001
From: Anian Schleyer <98647423+anian03@users.noreply.github.com>
Date: Tue, 17 Dec 2024 17:21:32 +0100
Subject: [PATCH 4/4] `Communication`: Fix scroll gesture conflict (#262)
* Update packages
* Set version number to 1.5.2
* Increase threshold for swipe to reply
* Resolve warning
---
.../xcshareddata/swiftpm/Package.resolved | 11 ++++++++++-
Artemis/Supporting/Info.plist | 2 +-
ArtemisKit/Package.swift | 3 ++-
.../MessageDetailViewModels/MessageCellModel.swift | 8 ++++----
.../Views/MessageDetailView/SwipeToReply.swift | 2 +-
5 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 824ead16..233e1569 100644
--- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "d98f8b8207dd2f669664cadc42e7d9f386e6aa80a1ad0c3e37c5b13e2081ecc9",
+ "originHash" : "60201ec1c26be7adad9fdf6611cc7c649da23b2d45f1aa466264a869dc6de3a1",
"pins" : [
{
"identity" : "apollon-ios-module",
@@ -10,6 +10,15 @@
"version" : "1.0.9"
}
},
+ {
+ "identity" : "artemis-ios-core-modules",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ls1intum/artemis-ios-core-modules",
+ "state" : {
+ "revision" : "f28438e6960df1c91bbfa59e53f46670430f509e",
+ "version" : "15.1.4"
+ }
+ },
{
"identity" : "collectionconcurrencykit",
"kind" : "remoteSourceControl",
diff --git a/Artemis/Supporting/Info.plist b/Artemis/Supporting/Info.plist
index 97e4eb76..4d03c5a2 100644
--- a/Artemis/Supporting/Info.plist
+++ b/Artemis/Supporting/Info.plist
@@ -21,7 +21,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 1.6.0
+ 1.5.2
CFBundleVersion
1
ITSAppUsesNonExemptEncryption
diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift
index a7eea141..886b931c 100644
--- a/ArtemisKit/Package.swift
+++ b/ArtemisKit/Package.swift
@@ -120,7 +120,8 @@ let package = Package(
.product(name: "SharedModels", package: "artemis-ios-core-modules"),
.product(name: "SharedServices", package: "artemis-ios-core-modules"),
.product(name: "UserStore", package: "artemis-ios-core-modules"),
- .product(name: "RswiftLibrary", package: "R.swift")
+ .product(name: "RswiftLibrary", package: "R.swift"),
+ .product(name: "Smile", package: "Smile")
],
plugins: [
.plugin(name: "RswiftGeneratePublicResources", package: "R.swift")
diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift
index 680b8f81..cae8a932 100644
--- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift
+++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift
@@ -100,10 +100,10 @@ class SwipeToReplyState {
/// Update all view properies associated with swiping to reply
func update(with distance: CGFloat) {
- overlayOffset = 200 * exp((distance - 10) / 30)
- messageBlur = max((-distance - 25) * 0.2 * blurIntensity, 0)
- overlayOpacity = max(0, min(-(distance + 40) * 0.05, 1))
- overlayScale = max(0, min(-(distance + 40) * 0.03, 1))
+ overlayOffset = 200 * exp((distance - 15) / 30)
+ messageBlur = max((-distance - 30) * 0.2 * blurIntensity, 0)
+ overlayOpacity = max(0, min(-(distance + 35) * 0.05, 1))
+ overlayScale = max(0, min(-(distance + 35) * 0.03, 1))
// If user dragged far enough to activate reply, let them know
if !swiped && distance < -70 {
diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift
index a1bed1a5..ca549ed1 100644
--- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift
+++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift
@@ -38,7 +38,7 @@ struct SwipeToReply: ViewModifier {
}
var swipeToReplyGesture: some Gesture {
- DragGesture(minimumDistance: 20)
+ DragGesture(minimumDistance: 25)
.onChanged { value in
// No swiping in Thread View
guard enabled else { return }