From 84de14d0603dcd1b380533bfcc759ec951700a78 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Mon, 4 Mar 2024 12:59:58 +0100 Subject: [PATCH] `General`: Release (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `Development`: Preview message detail view (#77) * Format * Preview MessageCell * Rename showHeader: isHeaderVisible * BaseMessage+IsContinuation * Make ConversationViewModel internal * Fix warning: - Reference to property 'stompClient' in closure requires explicit use of 'self' to make capture semantics explicit; this is an error in Swift 6 * Preview ReactionsView * Preview ConversationDaySection * Preview MessageDetailView * `Communication`: Usability of the conversation and thread views (#69) * Refine MessageCell * Align reaction leading * Apply system to send message overlay * Highlight message, not author * Pass isEmojiPickerButtonVisible through the environment * Hide image by height, not opacity * Inflect "reply" * `Communication`: Support user mentions and channel references (#47) * Format * Update overlay modifier - Deprecated: https://developer.apple.com/documentation/swiftui/view/overlay(_:alignment:) * Fix runtime warning: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. * Use getCourseMembers(courseId:searchLoginOrName:) API * Fix warning: - Deprecated: https://developer.apple.com/documentation/uikit/uiapplication/1622918-applicationiconbadgenumber * Add getChannelsPublicOverview * Duplicate SendMessageMemberPickerModel * Move SendMessageView * Improve search * Dependency injection * [R]un when this view initially appears * Stick to design system * Rename show*: is*Presented * Create SendMessageViewModel * Make Observables final * Move is[Modal]Presented * Test SendMessageViewModel * `Communication`: Navigate to exercises and lectures (#81) * Fix sheet * Add OpenURLAction * Check host * Inject UserSession * Split NavigationController * Format * `Exercise`: Add pull-to-refresh to ExerciseListView and ExerciseDetailView (#78) * initial pull-to-refresh feature * update apollon-ios-module dependency * `Modeling Exercise`: Improve Submit Button (#79) * add alert after submitting diagram * update Apollon-iOS-Module version * Improve submit button with colors * `Development`: Refactor ExerciseView (#82) * Make SendMessageViewModel primary * Initialize view model at caller * Move sendMessageType * Move isEditMode: isEditing * Format * Distinguish presentation * Organize ConversationViewModel * Extract button action * Inject dependencies * Remove conversationViewModel dependency * Create SendMessageViewModelDelegate; separate isLoading * Fix warning: - redundant_type_annotation * `Communication`: Restore draft message (#83) * Rename folder * Create schema * Fix runtime: Object 0x600000494860 of class AnyRepository deallocated with non-zero retain count 2. This object's deinit, or something called from it, may have created a strong reference to self which outlived deinit, resulting in a dangling reference. * Store context * Create MessagesRepository * Fix issue: https://github.com/ls1intum/artemis-ios/pull/83#discussion_r1508003860 * Add inverse relationship: https://www.hackingwithswift.com/quick-start/swiftdata/how-to-create-one-to-many-relationships * Change url to host; fix error: testRoundtrip(): failed: caught error: "SwiftDataError(_error: SwiftData.SwiftDataError._Error.unsupportedPredicate)" * Insert course model * Add message model * performOnDisappear * log verbose begin context access * Rename sendMessageType: configuration --------- Co-authored-by: Alexander Görtzen <40467337+AlexanderG2207@users.noreply.github.com> --- .../xcshareddata/swiftpm/Package.resolved | 12 +- ArtemisKit/Package.swift | 6 +- ArtemisKit/Sources/ArtemisKit/RootView.swift | 95 ++--- .../CourseRegistrationServiceImpl.swift | 2 +- .../ModelingExerciseViewModel.swift | 3 +- .../Views/EditModelingExerciseView.swift | 57 ++- .../ExerciseTab/ExerciseDetailView.swift | 17 +- .../ExerciseTab/ExerciseListView.swift | 5 + .../Resources/en.lproj/Localizable.strings | 6 + .../Models/BaseMessage+IsContinuation.swift | 25 ++ .../Messages/Models/ChannelIdAndNameDTO.swift | 11 + .../Messages/Models/Schema/Schema.swift | 13 + .../Messages/Models/Schema/SchemaV1.swift | 82 ++++ .../Messages/Navigation/MessagePath.swift | 44 +++ ...igationDestinationThreadViewModifier.swift | 19 + .../Repositories/MessagesRepository.swift | 124 ++++++ .../Resources/en.lproj/Localizable.strings | 6 +- .../MessagesService/MessagesService.swift | 5 + .../MessagesService/MessagesServiceImpl.swift | 27 +- .../MessagesService/MessagesServiceStub.swift | 197 ++++++++++ .../ConversationViewModel.swift | 170 ++++---- .../SendMessageMentionChannelViewModel.swift | 42 ++ .../SendMessageMentionMemberViewModel.swift | 33 ++ .../SendMessageViewModel.swift | 369 ++++++++++++++++++ .../SendMessageViewModelDelegate.swift | 24 ++ .../ConversationView/ConversationView.swift | 53 ++- .../MessageActionSheet.swift | 237 ++++++----- .../Views/MessageDetailView/MessageCell.swift | 160 +++++--- .../MessageDetailView/MessageDetailView.swift | 259 ++++++------ .../MessageDetailView/ReactionsView.swift | 224 ++++++----- .../MessagesTabView/CodeOfConductView.swift | 4 +- .../Messages/Views/SendMessageView.swift | 289 -------------- .../SendMessageExercisePicker.swift | 59 +++ .../SendMessageLecturePicker.swift | 33 ++ .../SendMessageMentionChannelView.swift | 58 +++ .../SendMessageMentionMemberView.swift | 58 +++ .../SendMessageViews/SendMessageView.swift | 173 ++++++++ .../Deeplinks/DeeplinkHandler.swift | 49 +-- .../Navigation/NavigationController.swift | 136 +------ .../Navigation/NavigationPathValues.swift | 77 ++++ .../Sources/Navigation/TabIdentifier.swift | 12 + .../Messages/MessagesRepositoryTests.swift | 31 ++ ...ndMessageChannelPickerViewModelTests.swift | 18 + .../Messages/SendMessageViewModelTests.swift | 88 +++++ 44 files changed, 2419 insertions(+), 993 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift create mode 100644 ArtemisKit/Sources/Messages/Models/ChannelIdAndNameDTO.swift create mode 100644 ArtemisKit/Sources/Messages/Models/Schema/Schema.swift create mode 100644 ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift create mode 100644 ArtemisKit/Sources/Messages/Navigation/MessagePath.swift create mode 100644 ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift create mode 100644 ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift create mode 100644 ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionMemberViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModelDelegate.swift delete mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionChannelView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionMemberView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift create mode 100644 ArtemisKit/Sources/Navigation/NavigationPathValues.swift create mode 100644 ArtemisKit/Sources/Navigation/TabIdentifier.swift create mode 100644 ArtemisKit/Tests/ArtemisKitTests/Messages/MessagesRepositoryTests.swift create mode 100644 ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageChannelPickerViewModelTests.swift create mode 100644 ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageViewModelTests.swift diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d614fa61..64d396c7 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/apollon-ios-module", "state" : { - "revision" : "1690e711415330b28e836cd8035e1805c0a4e479", - "version" : "1.0.2" + "revision" : "73a3999b4cdcdd0ae2b86426d65a7b75c6ac3af0", + "version" : "1.0.6" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "b5b5a7282691d27ea121aadc08b89369f3c8d566", - "version" : "9.0.0" + "revision" : "b14fec4f95b78587c9fa107353d0bca0895288b0", + "version" : "9.1.1" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "3ec0ab0bca4feb56e8b33e289c9496e89059dd08", - "version" : "7.10.2" + "revision" : "5b92f029fab2cce44386d28588098b5be0824ef5", + "version" : "7.11.0" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 0174e7da..b63beb2e 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "9.0.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "9.1.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ @@ -125,6 +125,8 @@ let package = Package( ]), .testTarget( name: "ArtemisKitTests", - dependencies: []) + dependencies: [ + "Messages" + ]) ] ) diff --git a/ArtemisKit/Sources/ArtemisKit/RootView.swift b/ArtemisKit/Sources/ArtemisKit/RootView.swift index 86dea441..b0531eb2 100644 --- a/ArtemisKit/Sources/ArtemisKit/RootView.swift +++ b/ArtemisKit/Sources/ArtemisKit/RootView.swift @@ -1,12 +1,12 @@ -import SwiftUI -import Login -import Dashboard +import Common import CourseRegistration import CourseView +import Dashboard +import Login +import Messages import Navigation import PushNotifications -import Common -import Messages +import SwiftUI public struct RootView: View { @@ -28,47 +28,7 @@ public struct RootView: View { if viewModel.didSetupNotifications { NavigationStack(path: $navigationController.path) { DashboardView() - .navigationDestination(for: CoursePath.self) { coursePath in - CourseView(courseId: coursePath.id) - .id(coursePath.id) - } - // Sadly the following navigationDestination have to be here since SwiftUI is ... - .navigationDestination(for: ExercisePath.self) { exercisePath in - if let course = exercisePath.coursePath.course, - let exercise = exercisePath.exercise { - ExerciseDetailView(course: course, exercise: exercise) - } else { - ExerciseDetailView(courseId: exercisePath.coursePath.id, exerciseId: exercisePath.id) - } - } - .navigationDestination(for: LecturePath.self) { lecturePath in - if let course = lecturePath.coursePath.course { - LectureDetailView(course: course, lectureId: lecturePath.id) - } else { - LectureDetailView(courseId: lecturePath.coursePath.id, lectureId: lecturePath.id) - } - } - .navigationDestination(for: MessagePath.self) { messagePath in - if let message = messagePath.message, - let conversationViewModel = messagePath.conversationViewModel as? ConversationViewModel { - MessageDetailView(viewModel: conversationViewModel, - message: message) - } else { - MessageDetailView(viewModel: ConversationViewModel(courseId: messagePath.coursePath.id, - conversationId: messagePath.conversationPath.id), - messageId: messagePath.id) - } - } - .navigationDestination(for: ConversationPath.self) { conversationPath in - if let conversation = conversationPath.conversation, - let course = conversationPath.coursePath.course { - ConversationView(course: course, - conversation: conversation) - } else { - ConversationView(courseId: conversationPath.coursePath.id, - conversationId: conversationPath.id) - } - } + .modifier(NavigationDestinationRootViewModifier()) } .onChange(of: navigationController.path) { log.debug("NavigationController count: \(navigationController.path.count)") @@ -77,6 +37,13 @@ public struct RootView: View { .onOpenURL { url in DeeplinkHandler.shared.handle(url: url) } + .environment(\.openURL, OpenURLAction { url in + if DeeplinkHandler.shared.handle(url: url) { + return .handled + } else { + return .systemAction + } + }) } else { PushNotificationSetupView() } @@ -97,3 +64,39 @@ public struct RootView: View { }) } } + +private struct NavigationDestinationRootViewModifier: ViewModifier { + func body(content: Content) -> some View { + content + .navigationDestination(for: CoursePath.self) { coursePath in + CourseView(courseId: coursePath.id) + .id(coursePath.id) + } + .navigationDestination(for: ExercisePath.self) { exercisePath in + if let course = exercisePath.coursePath.course, + let exercise = exercisePath.exercise { + ExerciseDetailView(course: course, exercise: exercise) + } else { + ExerciseDetailView(courseId: exercisePath.coursePath.id, exerciseId: exercisePath.id) + } + } + .navigationDestination(for: LecturePath.self) { lecturePath in + if let course = lecturePath.coursePath.course { + LectureDetailView(course: course, lectureId: lecturePath.id) + } else { + LectureDetailView(courseId: lecturePath.coursePath.id, lectureId: lecturePath.id) + } + } + .navigationDestination(for: ConversationPath.self) { conversationPath in + if let conversation = conversationPath.conversation, + let course = conversationPath.coursePath.course { + ConversationView(course: course, + conversation: conversation) + } else { + ConversationView(courseId: conversationPath.coursePath.id, + conversationId: conversationPath.id) + } + } + .modifier(NavigationDestinationThreadViewModifier()) + } +} diff --git a/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift b/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift index e3b9ce9b..c02596f7 100644 --- a/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift +++ b/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift @@ -48,7 +48,7 @@ class CourseRegistrationServiceImpl: CourseRegistrationService { let result = await client.sendRequest(RegisterCourseRequest(courseId: courseId)) switch result { - case .success((let response, _)): + case .success: return .success case .failure(let error): return .failure(error: UserFacingError(error: error)) diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift index 4c6b14a9..694a1b1b 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift @@ -102,7 +102,7 @@ class ModelingExerciseViewModel: BaseViewModel { } } - func submitSubmission() async { + func submitSubmission() async throws { guard var submitSubmission = submission as? ModelingSubmission, let umlModel else { return } @@ -117,6 +117,7 @@ class ModelingExerciseViewModel: BaseViewModel { try await exerciseService.updateSubmission(exerciseId: exercise.id, submission: submitSubmission) } catch { log.error(String(describing: error)) + throw error } } diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift index dd0a5cc7..094be0c9 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift @@ -13,6 +13,8 @@ import DesignLibrary struct EditModelingExerciseView: View { @StateObject var modelingViewModel: ModelingExerciseViewModel + @State private var showSubmissionAlert = false + @State private var isSubmissionSuccessful = false init(exercise: Exercise, participationId: Int, problemStatementURL: URLRequest) { self._modelingViewModel = StateObject(wrappedValue: ModelingExerciseViewModel(exercise: exercise, @@ -47,27 +49,70 @@ struct EditModelingExerciseView: View { if !modelingViewModel.diagramTypeUnsupported { HStack { ProblemStatementButton(modelingViewModel: modelingViewModel) - SubmitButton(modelingViewModel: modelingViewModel) + SubmitButton(modelingViewModel: modelingViewModel, showSubmissionAlert: $showSubmissionAlert, isSubmissionSuccessful: $isSubmissionSuccessful) } } } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) + .alert(isPresented: $showSubmissionAlert) { + if isSubmissionSuccessful { + return Alert( + title: Text(R.string.localizable.successfulSubmissionAlertTitle()), + message: Text(R.string.localizable.successfulSubmissionAlertMessage()) + ) + } else { + return Alert( + title: Text(R.string.localizable.failedSubmissionAlertTitle()), + message: Text(R.string.localizable.failedSubmissionAlertMessage()) + ) + } + } } } struct SubmitButton: View { @ObservedObject var modelingViewModel: ModelingExerciseViewModel + @Binding var showSubmissionAlert: Bool + @Binding var isSubmissionSuccessful: Bool + @State private var isSubmitting = false var body: some View { Button { - Task { - await modelingViewModel.submitSubmission() - } + submit() } label: { - Text(R.string.localizable.submitSubmission()) - }.buttonStyle(ArtemisButton()) + ZStack { + Text(R.string.localizable.submitSubmission()) + .opacity(isSubmitting ? 0 : 1) + // Show a Progress View, whilst the submision is being submitted + if isSubmitting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.Artemis.primaryButtonTextColor)) + } + } + } + .buttonStyle(ArtemisButton(buttonColor: showSubmissionAlert ? + (isSubmissionSuccessful ? Color.Artemis.resultSuccess : Color.Artemis.resultFailedColor) : + Color.Artemis.primaryButtonColor, + buttonTextColor: Color.Artemis.primaryButtonTextColor)) + .disabled(isSubmitting) + } + + private func submit() { + isSubmitting = true + Task { + do { + try await modelingViewModel.submitSubmission() + isSubmissionSuccessful = true + } catch { + isSubmissionSuccessful = false + } + withAnimation { + isSubmitting = false + showSubmissionAlert.toggle() + } + } } } diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift index 96d45a68..1aeba55e 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -62,7 +62,7 @@ public struct ExerciseDetailView: View { private var showFeedbackButton: Bool { switch exercise.value { - case .fileUpload, .modeling, .programming, .text: + case .fileUpload, .programming, .text: return true default: return false @@ -240,16 +240,23 @@ public struct ExerciseDetailView: View { .task { await loadExercise() } + .refreshable { + await refreshExercise() + } } private func loadExercise() async { if let exercise = exercise.value { setParticipationAndResultId(from: exercise) } else { - self.exercise = await ExerciseServiceFactory.shared.getExercise(exerciseId: exerciseId) - if let exercise = self.exercise.value { - setParticipationAndResultId(from: exercise) - } + await refreshExercise() + } + } + + private func refreshExercise() async { + self.exercise = await ExerciseServiceFactory.shared.getExercise(exerciseId: exerciseId) + if let exercise = self.exercise.value { + setParticipationAndResultId(from: exercise) } } diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift index 9ffabd1c..c7d93197 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift @@ -76,6 +76,11 @@ struct ExerciseListView: View { } } } + .refreshable { + if let courseId = viewModel.course.value?.id { + await viewModel.loadCourse(id: courseId) + } + } } } diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index 4e178c93..79c97747 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -66,6 +66,12 @@ "difficulty" = "Difficulty:"; "categories" = "Categories:"; +// Modeling Submission Alerts +"successfulSubmissionAlertTitle" = "Your submission was successful!"; +"successfulSubmissionAlertMessage" = "You can change your submission or wait for your feedback."; +"failedSubmissionAlertTitle" = "An error occurred..."; +"failedSubmissionAlertMessage" = "Please try again later."; + // Modeling Feedback "modelingFeedbackElement" = "Element"; "modelingFeedbackPoints" = "Points"; diff --git a/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift new file mode 100644 index 00000000..f7301231 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift @@ -0,0 +1,25 @@ +// +// BaseMessage+IsContinuation.swift +// +// +// Created by Nityananda Zbil on 07.02.24. +// + +import Foundation +import SharedModels + +// swiftlint:disable:next identifier_name +private let MAX_MINUTES_FOR_GROUPING_MESSAGES = 5 + +extension BaseMessage { + /// Whether the same author messaged multiple times within 5 minutes. + func isContinuation(of message: some BaseMessage) -> Bool { + guard author == message.author, + let lhs = creationDate, + let rhs = message.creationDate else { + return false + } + + return lhs < rhs.addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60)) + } +} diff --git a/ArtemisKit/Sources/Messages/Models/ChannelIdAndNameDTO.swift b/ArtemisKit/Sources/Messages/Models/ChannelIdAndNameDTO.swift new file mode 100644 index 00000000..daeba748 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/ChannelIdAndNameDTO.swift @@ -0,0 +1,11 @@ +// +// ChannelIdAndNameDTO.swift +// +// +// Created by Nityananda Zbil on 02.12.23. +// + +struct ChannelIdAndNameDTO: Codable, Identifiable { + let id: Int + let name: String +} diff --git a/ArtemisKit/Sources/Messages/Models/Schema/Schema.swift b/ArtemisKit/Sources/Messages/Models/Schema/Schema.swift new file mode 100644 index 00000000..5cdb85f8 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/Schema/Schema.swift @@ -0,0 +1,13 @@ +// +// Schema.swift +// +// +// Created by Nityananda Zbil on 29.02.24. +// + +// Alias for the most recent schema + +typealias ServerModel = SchemaV1.Server +typealias CourseModel = SchemaV1.Course +typealias ConversationModel = SchemaV1.Conversation +typealias MessageModel = SchemaV1.Message diff --git a/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift b/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift new file mode 100644 index 00000000..aaeed5d7 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift @@ -0,0 +1,82 @@ +// +// SchemaV1.swift +// +// +// Created by Nityananda Zbil on 29.02.24. +// + +import Foundation +import SwiftData + +enum SchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + + static var models: [any PersistentModel.Type] { + [Server.self, Course.self, Conversation.self, Message.self] + } + + @Model + final class Server { + @Attribute(.unique) + var host: String + + @Relationship(deleteRule: .cascade, inverse: \Course.server) + var courses: [Course] + + init(host: String, courses: [Course] = []) { + self.host = host + self.courses = courses + } + } + + @Model + final class Course { + var server: Server + + @Attribute(.unique) + var courseId: Int + + @Relationship(deleteRule: .cascade, inverse: \Conversation.course) + var conversations: [Conversation] + + init(server: Server, courseId: Int, conversations: [Conversation] = []) { + self.server = server + self.courseId = courseId + self.conversations = conversations + } + } + + @Model + final class Conversation { + var course: Course + + @Attribute(.unique) + var conversationId: Int + + /// A user's draft of a message, which they began to compose. + var messageDraft: String + + init(course: Course, conversationId: Int, messageDraft: String = "") { + self.course = course + self.conversationId = conversationId + self.messageDraft = messageDraft + } + } + + @Model + final class Message { + var conversation: Conversation + + @Attribute(.unique) + var messageId: Int + + /// A user's draft of an answer message, which they began to compose. + var answerMessageDraft: String + + init(conversation: Conversation, messageId: Int, answerMessageDraft: String = "") { + self.conversation = conversation + self.messageId = messageId + self.answerMessageDraft = answerMessageDraft + } + } +} diff --git a/ArtemisKit/Sources/Messages/Navigation/MessagePath.swift b/ArtemisKit/Sources/Messages/Navigation/MessagePath.swift new file mode 100644 index 00000000..6ca2ac17 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Navigation/MessagePath.swift @@ -0,0 +1,44 @@ +// +// MessagePath.swift +// +// +// Created by Nityananda Zbil on 07.02.24. +// + +import Common +import Navigation +import SharedModels +import SwiftUI + +struct MessagePath: Hashable { + let id: Int64 + let message: Binding> + let coursePath: CoursePath + let conversationPath: ConversationPath + let conversationViewModel: ConversationViewModel + + init?( + message: Binding>, + coursePath: CoursePath, + conversationPath: ConversationPath, + conversationViewModel: ConversationViewModel + ) { + guard let id = message.wrappedValue.value?.id else { + return nil + } + + self.id = id + self.message = message + self.coursePath = coursePath + self.conversationPath = conversationPath + self.conversationViewModel = conversationViewModel + } + + static func == (lhs: MessagePath, rhs: MessagePath) -> Bool { + lhs.id == rhs.id && lhs.coursePath == rhs.coursePath && lhs.conversationPath == rhs.conversationPath + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift b/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift new file mode 100644 index 00000000..c8224765 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift @@ -0,0 +1,19 @@ +// +// NavigationDestinationThreadViewModifier.swift +// +// +// Created by Nityananda Zbil on 07.02.24. +// + +import SwiftUI + +/// Navigates to a thread view of a message. +public struct NavigationDestinationThreadViewModifier: ViewModifier { + public init() {} + + public func body(content: Content) -> some View { + content.navigationDestination(for: MessagePath.self) { messagePath in + MessageDetailView(viewModel: messagePath.conversationViewModel, message: messagePath.message) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift new file mode 100644 index 00000000..c98e7d5a --- /dev/null +++ b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift @@ -0,0 +1,124 @@ +// +// MessagesRepository.swift +// +// +// Created by Nityananda Zbil on 28.02.24. +// + +import Common +import Foundation +import SwiftData + +@MainActor +final class MessagesRepository { + static let shared: MessagesRepository = { + do { + return try MessagesRepository() + } catch { + log.error(error) + fatalError("Failed to initialize repository") + } + }() + + private let context: ModelContext + + init() throws { + let schema = Schema(versionedSchema: SchemaV1.self) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: configuration) + self.context = container.mainContext + } + + deinit { + do { + try context.save() + } catch { + log.error(error) + } + } +} + +extension MessagesRepository { + + // MARK: - Server + + @discardableResult + func insertServer(host: String) -> ServerModel { + log.verbose("begin") + let server = ServerModel(host: host) + context.insert(server) + return server + } + + func fetchServer(host: String) throws -> ServerModel? { + log.verbose("begin") + let predicate = #Predicate { server in + server.host == host + } + return try context.fetch(FetchDescriptor(predicate: predicate)).first + } + + // MARK: - Course + + @discardableResult + func insertCourse(host: String, courseId: Int) throws -> CourseModel { + log.verbose("begin") + let server = try fetchServer(host: host) ?? insertServer(host: host) + let course = CourseModel(server: server, courseId: courseId) + context.insert(course) + return course + } + + func fetchCourse(host: String, courseId: Int) throws -> CourseModel? { + log.verbose("begin") + let predicate = #Predicate { course in + course.server.host == host + && course.courseId == courseId + } + return try context.fetch(FetchDescriptor(predicate: predicate)).first + } + + // MARK: - Conversation + + @discardableResult + func insertConversation(host: String, courseId: Int, conversationId: Int, messageDraft: String) throws -> ConversationModel { + log.verbose("begin") + let course = try fetchCourse(host: host, courseId: courseId) ?? insertCourse(host: host, courseId: courseId) + let conversation = ConversationModel(course: course, conversationId: conversationId, messageDraft: messageDraft) + context.insert(conversation) + return conversation + } + + func fetchConversation(host: String, courseId: Int, conversationId: Int) throws -> ConversationModel? { + log.verbose("begin") + let predicate = #Predicate { conversation in + conversation.course.server.host == host + && conversation.course.courseId == courseId + && conversation.conversationId == conversationId + } + return try context.fetch(FetchDescriptor(predicate: predicate)).first + } + + // MARK: - Message + + @discardableResult + func insertMessage(host: String, courseId: Int, conversationId: Int, messageId: Int, answerMessageDraft: String) throws -> MessageModel { + log.verbose("begin") + let conversation = try fetchConversation(host: host, courseId: courseId, conversationId: conversationId) + ?? insertConversation(host: host, courseId: courseId, conversationId: conversationId, messageDraft: "") + let message = MessageModel(conversation: conversation, messageId: messageId, answerMessageDraft: answerMessageDraft) + context.insert(message) + return message + } + + func fetchMessage(host: String, courseId: Int, conversationId: Int, messageId: Int) throws -> MessageModel? { + log.verbose("begin") + let predicate = #Predicate { message in + message.conversation.course.server.host == host + && message.conversation.course.courseId == courseId + && message.conversation.conversationId == conversationId + && message.messageId == messageId + } + return try context.fetch(FetchDescriptor(predicate: predicate)).first + } +} diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index ab17c4de..74c70d80 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -7,7 +7,11 @@ // MARK: SendMessageView "exercise" = "Exercise"; +"exercisesUnavailable" = "No Exercises"; "lecture" = "Lecture"; +"channelsUnavailable" = "No Channels"; +"lecturesUnavailable" = "No Lectures"; +"membersUnavailable" = "No Members"; "messageAction" = "Message %@"; // MARK: ReactionsView @@ -50,7 +54,7 @@ // MARK: ConversationView "noMessages" = "No Messages"; "noMessagesDescription" = "Write the first message to kickstart this conversation."; -"replyAction" = "%d reply"; +"reply" = "reply"; "new" = "New"; // MARK: CreateChannelView diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index 33dedaaf..130afaa2 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -88,6 +88,11 @@ protocol MessagesService { */ func getChannelsOverview(for courseId: Int) async -> DataState<[Channel]> + /** + * Perform a get request to retrieve all channels in the public overview in a specific course to the server. + */ + func getChannelsPublicOverview(for courseId: Int) async -> DataState<[ChannelIdAndNameDTO]> + /** * Perform a post request to add members to a specific channels 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 08d83331..4b2411cb 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by Sven Andabaka on 03.04.23. // @@ -437,6 +437,31 @@ class MessagesServiceImpl: MessagesService { } } + struct GetChannelsPublicOverviewRequest: APIRequest { + typealias Response = [ChannelIdAndNameDTO] + + let courseId: Int + + var method: HTTPMethod { + return .get + } + + var resourceName: String { + return "api/courses/\(courseId)/channels/public-overview" + } + } + + func getChannelsPublicOverview(for courseId: Int) async -> DataState<[ChannelIdAndNameDTO]> { + let result = await client.sendRequest(GetChannelsPublicOverviewRequest(courseId: courseId)) + + switch result { + case let .success((channels, _)): + return .done(response: channels) + case let .failure(error): + return .failure(error: UserFacingError(error: error)) + } + } + struct AddMembersToChannelRequest: APIRequest { typealias Response = RawResponse diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift new file mode 100644 index 00000000..bf2d5365 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift @@ -0,0 +1,197 @@ +// +// MessagesServiceStub.swift +// +// +// Created by Nityananda Zbil on 14.02.24. +// + +import Common +import Foundation +import SharedModels + +struct MessagesServiceStub { + static let now: Date = { + // swiftlint:disable:next force_try + try! Date("2024-01-08T9:41:32Z", strategy: .iso8601) + }() + + static let course: Course = { + let course = Course(id: 1, courseInformationSharingConfiguration: .communicationAndMessaging) + return course + }() + + static let conversation: Conversation = { + var oneToOneChat = OneToOneChat(id: 1) + oneToOneChat.lastReadDate = now + let conversation = Conversation.oneToOneChat(conversation: oneToOneChat) + return conversation + }() + + static let alice: ConversationUser = { + var author = ConversationUser(id: 1) + author.name = "Alice" + return author + }() + + static let bob: ConversationUser = { + var author = ConversationUser(id: 2) + author.name = "Bob" + return author + }() + + static let message: Message = { + var message = Message(id: 1) + message.author = alice + message.creationDate = Calendar.current.date(byAdding: .minute, value: 1, to: now) + + message.content = "Hello, world!" + + message.updatedDate = Calendar.current.date(byAdding: .minute, value: 2, to: now) + + message.reactions = [ + Reaction(id: 1), + Reaction(id: 2), + Reaction(id: 3, emojiId: "heart") + ] + + message.answers = [answer] + + return message + }() + + static let answer: AnswerMessage = { + var answer = AnswerMessage(id: 2) + answer.author = bob + answer.creationDate = Calendar.current.date(byAdding: .minute, value: 3, to: now) + answer.content = "Hello, Alice!" + return answer + }() + + static let continuation: Message = { + var message = Message(id: 3) + message.author = alice + message.creationDate = Calendar.current.date(byAdding: .minute, value: 4, to: now) + message.content = "How are you?" + return message + }() + + static let reply: Message = { + var message = Message(id: 4) + message.author = bob + message.creationDate = Calendar.current.date(byAdding: .minute, value: 4, to: now) + message.content = "I am great." + return message + }() + + var messages: [Message] = [message, continuation, reply] +} + +extension MessagesServiceStub: MessagesService { + func getConversations(for courseId: Int) async -> DataState<[Conversation]> { + .loading + } + + func updateIsConversationFavorite(for courseId: Int, and conversationId: Int64, isFavorite: Bool) async -> NetworkResponse { + .loading + } + + func updateIsConversationMuted(for courseId: Int, and conversationId: Int64, isMuted: Bool) async -> NetworkResponse { + .loading + } + + func updateIsConversationHidden(for courseId: Int, and conversationId: Int64, isHidden: Bool) async -> NetworkResponse { + .loading + } + + func getMessages(for courseId: Int, and conversationId: Int64, size: Int) async -> DataState<[Message]> { + .done(response: messages) + } + + func sendMessage(for courseId: Int, conversation: Conversation, content: String) async -> NetworkResponse { + .loading + } + + func sendAnswerMessage(for courseId: Int, message: Message, content: String) async -> NetworkResponse { + .loading + } + + func deleteMessage(for courseId: Int, messageId: Int64) async -> NetworkResponse { + .loading + } + + func deleteAnswerMessage(for courseId: Int, anserMessageId: Int64) async -> NetworkResponse { + .loading + } + + func editMessage(for courseId: Int, message: Message) async -> NetworkResponse { + .loading + } + + func editAnswerMessage(for courseId: Int, answerMessage: AnswerMessage) async -> NetworkResponse { + .loading + } + + func addReactionToAnswerMessage(for courseId: Int, answerMessage: AnswerMessage, emojiId: String) async -> NetworkResponse { + .loading + } + + func addReactionToMessage(for courseId: Int, message: Message, emojiId: String) async -> NetworkResponse { + .loading + } + + func removeReactionFromMessage(for courseId: Int, reaction: Reaction) async -> NetworkResponse { + .loading + } + + func getChannelsOverview(for courseId: Int) async -> DataState<[Channel]> { + .loading + } + + func getChannelsPublicOverview(for courseId: Int) async -> DataState<[ChannelIdAndNameDTO]> { + .done(response: [ChannelIdAndNameDTO(id: 2, name: "announcement")]) + } + + func addMembersToChannel(for courseId: Int, channelId: Int64, usernames: [String]) async -> NetworkResponse { + .loading + } + + func removeMembersFromChannel(for courseId: Int, channelId: Int64, usernames: [String]) async -> NetworkResponse { + .loading + } + + func addMembersToGroupChat(for courseId: Int, groupChatId: Int64, usernames: [String]) async -> NetworkResponse { + .loading + } + + func removeMembersFromGroupChat(for courseId: Int, groupChatId: Int64, usernames: [String]) async -> NetworkResponse { + .loading + } + + func createChannel(for courseId: Int, name: String, description: String?, isPrivate: Bool, isAnnouncement: Bool) async -> DataState { + .loading + } + + func searchForUsers(for courseId: Int, searchText: String) async -> DataState<[ConversationUser]> { + .loading + } + + func createGroupChat(for courseId: Int, usernames: [String]) async -> DataState { + .loading + } + + func createOneToOneChat(for courseId: Int, usernames: [String]) async -> DataState { + .loading + } + + func getMembersOfConversation(for courseId: Int, conversationId: Int64, page: Int) async -> DataState<[ConversationUser]> { + .loading + } + + func archiveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { + .loading + } + + func unarchiveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { + .loading + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index 8cceb13a..2251bef0 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -14,7 +14,7 @@ import UserStore // swiftlint:disable file_length @MainActor -public class ConversationViewModel: BaseViewModel { +class ConversationViewModel: BaseViewModel { @Published var dailyMessages: DataState<[Date: [Message]]> = .loading @Published var conversation: DataState = .loading @@ -28,23 +28,52 @@ public class ConversationViewModel: BaseViewModel { private var size = 50 - public init(course: Course, conversation: Conversation) { + private let courseService: CourseService + private let messagesService: MessagesService + private let stompClient: ArtemisStompClient + private let userSession: UserSession + + init( + course: Course, + conversation: Conversation, + courseService: CourseService = CourseServiceFactory.shared, + messagesService: MessagesService = MessagesServiceFactory.shared, + stompClient: ArtemisStompClient = .shared, + userSession: UserSession = .shared + ) { self._course = Published(wrappedValue: .done(response: course)) self.courseId = course.id self._conversation = Published(wrappedValue: .done(response: conversation)) self.conversationId = conversation.id + self.courseService = courseService + self.messagesService = messagesService + self.stompClient = stompClient + self.userSession = userSession + super.init() subscribeToConversationTopic() } - public init(courseId: Int, conversationId: Int64) { + init( + courseId: Int, + conversationId: Int64, + courseService: CourseService = CourseServiceFactory.shared, + messagesService: MessagesService = MessagesServiceFactory.shared, + stompClient: ArtemisStompClient = .shared, + userSession: UserSession = .shared + ) { self.courseId = courseId self.conversationId = conversationId self._conversation = Published(wrappedValue: .loading) self._course = Published(wrappedValue: .loading) + self.courseService = courseService + self.messagesService = messagesService + self.stompClient = stompClient + self.userSession = userSession + super.init() Task { @@ -60,6 +89,13 @@ public class ConversationViewModel: BaseViewModel { deinit { websocketSubscriptionTask?.cancel() } +} + +// MARK: - Internal + +extension ConversationViewModel { + + // MARK: Load func loadFurtherMessages() async { size += 50 @@ -73,7 +109,7 @@ public class ConversationViewModel: BaseViewModel { } func loadMessages() async { - let result = await MessagesServiceFactory.shared.getMessages(for: courseId, and: conversationId, size: size) + let result = await messagesService.getMessages(for: courseId, and: conversationId, size: size) switch result { case .loading: @@ -83,13 +119,15 @@ public class ConversationViewModel: BaseViewModel { case .done(let response): var dailyMessages: [Date: [Message]] = [:] - response.forEach { message in + for message in response { if let date = message.creationDate?.startOfDay { if dailyMessages[date] == nil { dailyMessages[date] = [message] } else { dailyMessages[date]?.append(message) - dailyMessages[date] = dailyMessages[date]?.sorted(by: { $0.creationDate! < $1.creationDate! }) + dailyMessages[date] = dailyMessages[date]?.sorted { + $0.creationDate! < $1.creationDate! + } } } } @@ -100,7 +138,7 @@ public class ConversationViewModel: BaseViewModel { func loadMessage(messageId: Int64) async -> DataState { // TODO: add API to only load one single message - let result = await MessagesServiceFactory.shared.getMessages(for: courseId, and: conversationId, size: size) + let result = await messagesService.getMessages(for: courseId, and: conversationId, size: size) switch result { case .loading: @@ -117,7 +155,7 @@ public class ConversationViewModel: BaseViewModel { func loadAnswerMessage(answerMessageId: Int64) async -> DataState { // TODO: add API to only load one single answer message - let result = await MessagesServiceFactory.shared.getMessages(for: courseId, and: conversationId, size: size) + let result = await messagesService.getMessages(for: courseId, and: conversationId, size: size) switch result { case .loading: @@ -133,59 +171,15 @@ public class ConversationViewModel: BaseViewModel { } } - func sendMessage(text: String) async -> NetworkResponse { - guard let conversation = conversation.value else { - let error = UserFacingError(title: R.string.localizable.conversationNotLoaded()) - presentError(userFacingError: error) - return .failure(error: error) - } - isLoading = true - let result = await MessagesServiceFactory.shared.sendMessage(for: courseId, conversation: conversation, content: text) - switch result { - case .notStarted, .loading: - isLoading = false - case .success: - shouldScrollToId = "bottom" - await loadMessages() - isLoading = false - case .failure(let error): - isLoading = false - if let apiClientError = error as? APIClientError { - presentError(userFacingError: UserFacingError(error: apiClientError)) - } else { - presentError(userFacingError: UserFacingError(title: error.localizedDescription)) - } - } - return result - } - - func sendAnswerMessage(text: String, for message: Message, completion: () async -> Void) async -> NetworkResponse { - isLoading = true - let result = await MessagesServiceFactory.shared.sendAnswerMessage(for: courseId, message: message, content: text) - switch result { - case .notStarted, .loading: - isLoading = false - case .success: - await completion() - isLoading = false - case .failure(let error): - isLoading = false - if let apiClientError = error as? APIClientError { - presentError(userFacingError: UserFacingError(error: apiClientError)) - } else { - presentError(userFacingError: UserFacingError(title: error.localizedDescription)) - } - } - return result - } + // MARK: React func addReactionToMessage(for message: Message, emojiId: String) async -> DataState { isLoading = true let result: NetworkResponse if let reaction = message.getReactionFromMe(emojiId: emojiId) { - result = await MessagesServiceFactory.shared.removeReactionFromMessage(for: courseId, reaction: reaction) + result = await messagesService.removeReactionFromMessage(for: courseId, reaction: reaction) } else { - result = await MessagesServiceFactory.shared.addReactionToMessage(for: courseId, message: message, emojiId: emojiId) + result = await messagesService.addReactionToMessage(for: courseId, message: message, emojiId: emojiId) } switch result { case .notStarted, .loading: @@ -214,9 +208,9 @@ public class ConversationViewModel: BaseViewModel { isLoading = true let result: NetworkResponse if let reaction = message.getReactionFromMe(emojiId: emojiId) { - result = await MessagesServiceFactory.shared.removeReactionFromMessage(for: courseId, reaction: reaction) + result = await messagesService.removeReactionFromMessage(for: courseId, reaction: reaction) } else { - result = await MessagesServiceFactory.shared.addReactionToAnswerMessage(for: courseId, answerMessage: message, emojiId: emojiId) + result = await messagesService.addReactionToAnswerMessage(for: courseId, answerMessage: message, emojiId: emojiId) } switch result { case .notStarted, .loading: @@ -241,13 +235,15 @@ public class ConversationViewModel: BaseViewModel { } } + // MARK: Delete + func deleteMessage(messageId: Int64?) async -> Bool { guard let messageId else { presentError(userFacingError: UserFacingError(title: R.string.localizable.deletionErrorLabel())) return false } - let result = await MessagesServiceFactory.shared.deleteMessage(for: courseId, messageId: messageId) + let result = await messagesService.deleteMessage(for: courseId, messageId: messageId) switch result { case .notStarted, .loading: @@ -267,37 +263,7 @@ public class ConversationViewModel: BaseViewModel { return false } - let result = await MessagesServiceFactory.shared.deleteAnswerMessage(for: courseId, anserMessageId: messageId) - - switch result { - case .notStarted, .loading: - return false - case .success: - await loadMessages() - return true - case .failure(let error): - presentError(userFacingError: UserFacingError(title: error.localizedDescription)) - return false - } - } - - func editMessage(message: Message) async -> Bool { - let result = await MessagesServiceFactory.shared.editMessage(for: courseId, message: message) - - switch result { - case .notStarted, .loading: - return false - case .success: - await loadMessages() - return true - case .failure(let error): - presentError(userFacingError: UserFacingError(title: error.localizedDescription)) - return false - } - } - - func editAnswerMessage(answerMessage: AnswerMessage) async -> Bool { - let result = await MessagesServiceFactory.shared.editAnswerMessage(for: courseId, answerMessage: answerMessage) + let result = await messagesService.deleteAnswerMessage(for: courseId, anserMessageId: messageId) switch result { case .notStarted, .loading: @@ -312,11 +278,14 @@ public class ConversationViewModel: BaseViewModel { } } -// MARK: Start (initializer) +// MARK: - Private private extension ConversationViewModel { + + // MARK: Start (initializer) + func loadConversation() async { - let result = await MessagesServiceFactory.shared.getConversations(for: courseId) + let result = await messagesService.getConversations(for: courseId) switch result { case .loading: @@ -333,7 +302,7 @@ private extension ConversationViewModel { } func loadCourse() async { - let result = await CourseServiceFactory.shared.getCourse(courseId: courseId) + let result = await courseService.getCourse(courseId: courseId) switch result { case .loading: @@ -349,31 +318,34 @@ private extension ConversationViewModel { let topic: String if conversation.value?.baseConversation.type == .channel { topic = WebSocketTopic.makeChannelNotifications(courseId: courseId) - } else if let id = UserSession.shared.user?.id { + } else if let id = userSession.user?.id { topic = WebSocketTopic.makeConversationNotifications(userId: id) } else { return } - if ArtemisStompClient.shared.didSubscribeTopic(topic) { + if stompClient.didSubscribeTopic(topic) { return } websocketSubscriptionTask = Task { [weak self] in - let stream = ArtemisStompClient.shared.subscribe(to: topic) + guard let stream = self?.stompClient.subscribe(to: topic) else { + return + } for await message in stream { guard let messageWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: MessageWebsocketDTO.self, message: message) else { continue } - self?.onMessageReceived(messageWebsocketDTO: messageWebsocketDTO) + guard let self else { + return + } + onMessageReceived(messageWebsocketDTO: messageWebsocketDTO) } } } -} -// MARK: Receive message + // MARK: Receive message -private extension ConversationViewModel { func onMessageReceived(messageWebsocketDTO: MessageWebsocketDTO) { // Guard message corresponds to conversation guard messageWebsocketDTO.post.conversation?.id == conversation.value?.id else { diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift new file mode 100644 index 00000000..7a50d9c4 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift @@ -0,0 +1,42 @@ +// +// SendMessageMentionChannelViewModel.swift +// +// +// Created by Nityananda Zbil on 02.12.23. +// + +import Common +import SharedModels +import SharedServices +import SwiftUI + +@Observable +final class SendMessageMentionChannelViewModel { + + let course: Course + + var channels: DataState<[ChannelIdAndNameDTO]> = .loading + + private let messagesService: MessagesService + + init( + course: Course, + messagesService: MessagesService = MessagesServiceFactory.shared + ) { + self.course = course + self.messagesService = messagesService + } + + func search(idOrName: String) async { + let channels = await messagesService.getChannelsPublicOverview(for: course.id) + if case let .done(channels) = channels { + let filtered = channels.filter { channel in + let range = channel.name.range(of: idOrName, options: [.caseInsensitive, .diacriticInsensitive]) + return range != nil + } + self.channels = .done(response: filtered) + } else { + self.channels = channels + } + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionMemberViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionMemberViewModel.swift new file mode 100644 index 00000000..8c16fe54 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionMemberViewModel.swift @@ -0,0 +1,33 @@ +// +// SendMessageMentionMemberViewModel.swift +// +// +// Created by Nityananda Zbil on 28.10.23. +// + +import Common +import SharedModels +import SharedServices +import SwiftUI + +@Observable +final class SendMessageMentionMemberViewModel { + + let course: Course + + var members: DataState<[UserNameAndLoginDTO]> = .loading + + private let courseService: CourseService + + init( + course: Course, + courseService: CourseService = CourseServiceFactory.shared + ) { + self.course = course + self.courseService = courseService + } + + func search(loginOrName: String) async { + members = await courseService.getCourseMembers(courseId: course.id, searchLoginOrName: loginOrName) + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift new file mode 100644 index 00000000..97ad0648 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -0,0 +1,369 @@ +// +// SendMessageViewModel.swift +// +// +// Created by Nityananda Zbil on 22.02.24. +// + +import APIClient +import Common +import Foundation +import SharedModels +import UserStore + +extension SendMessageViewModel { + enum Configuration { + case message + case answerMessage(Message, () async -> Void) + case editMessage(Message, () -> Void) + case editAnswerMessage(AnswerMessage, () -> Void) + } + + enum ConditionalPresentation { + case memberPicker + case channelPicker + } + + enum ModalPresentation: Identifiable { + case exercisePicker + case lecturePicker + + var id: Self { + self + } + } +} + +@Observable +final class SendMessageViewModel { + let course: Course + let conversation: Conversation + let configuration: Configuration + + private let delegate: SendMessageViewModelDelegate + private let messagesRepository: MessagesRepository + private let messagesService: MessagesService + private let userSession: UserSession + + // MARK: Loading + + var isLoading = false + + // MARK: Text + + var text = "" + + var isEditing: Bool { + switch configuration { + case .message, .answerMessage: + return false + case .editMessage, .editAnswerMessage: + return true + } + } + + // MARK: Presentation + + var conditionalPresentation: ConditionalPresentation? { + if !isMemberPickerSuppressed, searchMember() != nil { + .memberPicker + } else if !isChannelPickerSuppressed, searchChannel() != nil { + .channelPicker + } else { + nil + } + } + + var isMemberPickerSuppressed = false + var isChannelPickerSuppressed = false + + var modalPresentation: ModalPresentation? + + // MARK: Life cycle + + init( + course: Course, + conversation: Conversation, + configuration: Configuration, + delegate: SendMessageViewModelDelegate, + messagesRepository: MessagesRepository = .shared, + messagesService: MessagesService = MessagesServiceFactory.shared, + userSession: UserSession = .shared + ) { + self.course = course + self.conversation = conversation + self.configuration = configuration + + self.delegate = delegate + self.messagesRepository = messagesRepository + self.messagesService = messagesService + self.userSession = userSession + } +} + +// MARK: - Actions + +extension SendMessageViewModel { + @MainActor + func performOnAppear() { + do { + switch configuration { + case .message: + if let host = userSession.institution?.baseURL?.host() { + let conversation = try messagesRepository.fetchConversation( + host: host, + courseId: course.id, + conversationId: Int(conversation.id)) + text = conversation?.messageDraft ?? "" + } + case let .answerMessage(message, _): + if let host = userSession.institution?.baseURL?.host() { + let message = try messagesRepository.fetchMessage( + host: host, + courseId: course.id, + conversationId: Int(conversation.id), + messageId: Int(message.id)) + text = message?.answerMessageDraft ?? "" + } + case let .editMessage(message, _): + text = message.content ?? "" + case let .editAnswerMessage(message, _): + text = message.content ?? "" + } + } catch { + log.error(error) + } + } + + @MainActor + func performOnDisappear() { + do { + if let host = userSession.institution?.baseURL?.host() { + switch configuration { + case .message: + try messagesRepository.insertConversation( + host: host, + courseId: course.id, + conversationId: Int(conversation.id), + messageDraft: text) + case let .answerMessage(message, _): + try messagesRepository.insertMessage( + host: host, + courseId: course.id, + conversationId: Int(conversation.id), + messageId: Int(message.id), + answerMessageDraft: text) + default: + break + } + } + } catch { + log.error(error) + } + } + + // MARK: Toolbar + + func didTapBoldButton() { + text.append("****") + } + + func didTapItalicButton() { + text.append("**") + } + + func didTapUnderlineButton() { + text.append("") + } + + func didTapBlockquoteButton() { + text.append("> Reference") + } + + func didTapCodeButton() { + text.append("``") + } + + func didTapCodeBlockButton() { + text.append(""" + ```java + Source Code + ``` + """) + } + + func didTapLinkButton() { + text.append("[](http://)") + } + + func didTapAtButton() { + if conditionalPresentation == .memberPicker { + isMemberPickerSuppressed = true + } else { + isMemberPickerSuppressed = false + text += "@" + } + } + + func didTapNumberButton() { + if conditionalPresentation == .channelPicker { + isChannelPickerSuppressed = true + } else { + isChannelPickerSuppressed = false + text += "#" + } + } + + // MARK: Send Message + + func didTapSendButton() { + isLoading = true + Task { @MainActor in + var result: NetworkResponse? + switch configuration { + case .message: + result = await sendMessage(text: text) + case let .answerMessage(message, completion): + result = await sendAnswerMessage(text: text, for: message, completion: completion) + case let .editMessage(message, completion): + var newMessage = message + newMessage.content = text + let success = await editMessage(message: newMessage) + isLoading = false + if success { + completion() + } + case let .editAnswerMessage(message, completion): + var newMessage = message + newMessage.content = text + let success = await editAnswerMessage(answerMessage: newMessage) + isLoading = false + if success { + completion() + } + } + switch result { + case .success: + text = "" + default: + return + } + } + } + + @MainActor + private func sendMessage(text: String) async -> NetworkResponse { + isLoading = true + let result = await messagesService.sendMessage(for: course.id, conversation: conversation, content: text) + switch result { + case .notStarted, .loading: + isLoading = false + case .success: + delegate.scrollToId("bottom") + await delegate.loadMessages() + isLoading = false + case .failure(let error): + isLoading = false + if let apiClientError = error as? APIClientError { + delegate.presentError(UserFacingError(error: apiClientError)) + } else { + delegate.presentError(UserFacingError(title: error.localizedDescription)) + } + } + return result + } + + @MainActor + private func sendAnswerMessage(text: String, for message: Message, completion: () async -> Void) async -> NetworkResponse { + isLoading = true + let result = await messagesService.sendAnswerMessage(for: course.id, message: message, content: text) + switch result { + case .notStarted, .loading: + isLoading = false + case .success: + await completion() + isLoading = false + case .failure(let error): + isLoading = false + if let apiClientError = error as? APIClientError { + delegate.presentError(UserFacingError(error: apiClientError)) + } else { + delegate.presentError(UserFacingError(title: error.localizedDescription)) + } + } + return result + } + + @MainActor + private func editMessage(message: Message) async -> Bool { + let result = await messagesService.editMessage(for: course.id, message: message) + + switch result { + case .notStarted, .loading: + return false + case .success: + await delegate.loadMessages() + return true + case .failure(let error): + delegate.presentError(UserFacingError(title: error.localizedDescription)) + return false + } + } + + @MainActor + private func editAnswerMessage(answerMessage: AnswerMessage) async -> Bool { + let result = await messagesService.editAnswerMessage(for: course.id, answerMessage: answerMessage) + + switch result { + case .notStarted, .loading: + return false + case .success: + await delegate.loadMessages() + return true + case .failure(let error): + delegate.presentError(UserFacingError(title: error.localizedDescription)) + return false + } + } + + // MARK: Search and Replace + + func searchChannel() -> Substring? { + let matches = text.matches(of: #/#(?[\w-]*)/#) + return matches.last?.candidate + } + + func replace(channel: ChannelIdAndNameDTO) { + guard let candidate = searchChannel() else { + return + } + + // Replaces all occurrences. Otherwise, we need to get the match. + let range = Range?.none + + text = text.replacingOccurrences( + of: "#" + candidate, + with: "[channel]\(channel.name)(\(channel.id))[/channel]", + range: range) + } + + func searchMember() -> Substring? { + let matches = text.matches(of: #/@(?[\w]*)/#) + return matches.last?.candidate + } + + func replace(member: UserNameAndLoginDTO) { + guard let candidate = searchMember(), + let name = member.name, let login = member.login else { + return + } + + // Replaces all occurrences. Otherwise, we need to get the match. + let range = Range?.none + + text = text.replacingOccurrences( + of: "@" + candidate, + with: "[user]\(name)(\(login))[/user]", + range: range) + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModelDelegate.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModelDelegate.swift new file mode 100644 index 00000000..f95a1a34 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModelDelegate.swift @@ -0,0 +1,24 @@ +// +// SendMessageViewModelDelegate.swift +// +// +// Created by Nityananda Zbil on 28.02.24. +// + +import Common +import SwiftUI + +@MainActor +struct SendMessageViewModelDelegate { + let loadMessages: () async -> Void + let presentError: (UserFacingError) -> Void + let scrollToId: (String) -> Void +} + +extension SendMessageViewModelDelegate { + init(_ conversationViewModel: ConversationViewModel) { + self.loadMessages = conversationViewModel.loadMessages + self.presentError = conversationViewModel.presentError(userFacingError:) + self.scrollToId = { conversationViewModel.shouldScrollToId = $0 } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index 712d597d..456cb1ac 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -12,9 +12,6 @@ import Navigation import SharedModels import SwiftUI -// swiftlint:disable:next identifier_name -private let MAX_MINUTES_FOR_GROUPING_MESSAGES = 5 - public struct ConversationView: View { @EnvironmentObject var navigationController: NavigationController @@ -91,8 +88,17 @@ public struct ConversationView: View { } } } - if isAllowedToPost { - SendMessageView(viewModel: viewModel, sendMessageType: .message) + if isAllowedToPost, + let course = viewModel.course.value, + let conversation = viewModel.conversation.value { + SendMessageView( + viewModel: SendMessageViewModel( + course: course, + conversation: conversation, + configuration: .message, + delegate: SendMessageViewModelDelegate(viewModel) + ) + ) } } .toolbar { @@ -154,16 +160,10 @@ private struct ConversationDaySection: View { day: day, message: message, conversationPath: conversationPath, - showHeader: (index == 0 ? true : showHeader(message: message, previousMessage: messages[index - 1]))) + isHeaderVisible: index == 0 || !message.isContinuation(of: messages[index - 1])) } } } - - // header is not shown if same person messages multiple times within 5 minutes - private func showHeader(message: Message, previousMessage: Message) -> Bool { - !(message.author == previousMessage.author && - message.creationDate ?? .now < (previousMessage.creationDate ?? .yesterday).addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60))) - } } private struct MessageCellWrapper: View { @@ -172,7 +172,7 @@ private struct MessageCellWrapper: View { let day: Date let message: Message let conversationPath: ConversationPath - let showHeader: Bool + let isHeaderVisible: Bool private var messageBinding: Binding> { Binding(get: { @@ -194,7 +194,7 @@ private struct MessageCellWrapper: View { viewModel: viewModel, message: messageBinding, conversationPath: conversationPath, - showHeader: showHeader) + isHeaderVisible: isHeaderVisible) } } @@ -236,3 +236,28 @@ private struct PullToRefresh: View { .padding(.top, -50) } } + +#Preview { + ConversationDaySection( + viewModel: { + let viewModel = ConversationViewModel( + course: MessagesServiceStub.course, + conversation: MessagesServiceStub.conversation) + viewModel.dailyMessages = .done(response: [ + MessagesServiceStub.now: [ + MessagesServiceStub.message, + MessagesServiceStub.continuation, + MessagesServiceStub.reply + ] + ]) + return viewModel + }(), + day: MessagesServiceStub.now, + messages: [ + MessagesServiceStub.message, + MessagesServiceStub.continuation, + MessagesServiceStub.reply + ], + conversationPath: ConversationPath(id: 1, coursePath: CoursePath(course: MessagesServiceStub.course)) + ) +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift index 1cf96f55..97f50d9f 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift @@ -1,17 +1,17 @@ // // SwiftUIView.swift -// +// // // Created by Sven Andabaka on 08.04.23. // -import SwiftUI -import SharedModels -import UserStore +import Common import EmojiPicker import Navigation -import Common +import SharedModels import Smile +import SwiftUI +import UserStore struct MessageActionSheet: View { @@ -27,13 +27,17 @@ struct MessageActionSheet: View { @State private var showEditSheet = false var isAbleToEditDelete: Bool { - guard let message = message.value else { return false } + guard let message = message.value else { + return false + } if message.isCurrentUserAuthor { return true } - guard let channel = viewModel.conversation.value?.baseConversation as? Channel else { return false } + guard let channel = viewModel.conversation.value?.baseConversation as? Channel else { + return false + } if channel.hasChannelModerationRights ?? false && message is Message { return true } @@ -51,28 +55,33 @@ struct MessageActionSheet: View { EmojiTextButton(viewModel: viewModel, message: $message, emoji: "🚀") EmojiPickerButton(viewModel: viewModel, message: $message) } - .padding(.l) + .padding(.l) if message.value is Message, let conversationPath { Divider() - Button(action: { - if let messagePath = MessagePath(message: $message, coursePath: conversationPath.coursePath, conversationPath: conversationPath, conversationViewModel: viewModel) { + Button { + if let messagePath = MessagePath( + message: $message, + coursePath: conversationPath.coursePath, + conversationPath: conversationPath, + conversationViewModel: viewModel + ) { dismiss() navigationController.path.append(messagePath) } else { viewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } - }, label: { + } label: { ButtonContent(title: R.string.localizable.replyInThread(), icon: "text.bubble.fill") - }) + } } Divider() - Button(action: { + Button { UIPasteboard.general.string = message.value?.content dismiss() - }, label: { + } label: { ButtonContent(title: R.string.localizable.copyText(), icon: "clipboard.fill") - }) + } editDeleteSection @@ -80,75 +89,99 @@ struct MessageActionSheet: View { } Spacer() } - .padding(.vertical, .xxl) - .loadingIndicator(isLoading: $viewModel.isLoading) - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + .padding(.vertical, .xxl) + .loadingIndicator(isLoading: $viewModel.isLoading) + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) } +} +private extension MessageActionSheet { var editDeleteSection: some View { Group { if isAbleToEditDelete { Divider() - Button(action: { + Button { showEditSheet = true - }, label: { + } label: { ButtonContent(title: R.string.localizable.editMessage(), icon: "pencil") - }) - .sheet(isPresented: $showEditSheet) { - NavigationView { - Group { - if let message = message.value as? Message { - SendMessageView(viewModel: viewModel, sendMessageType: .editMessage(message, { self.dismiss() })) - } else if let answerMessage = message.value as? AnswerMessage { - SendMessageView(viewModel: viewModel, sendMessageType: .editAnswerMessage(answerMessage, { self.dismiss() })) - } else { - Text(R.string.localizable.loading()) - } - } - .navigationTitle(R.string.localizable.editMessage()) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(R.string.localizable.cancel()) { - showEditSheet = false - } - } - } - }.presentationDetents([.height(200), .medium]) - } + } + .sheet(isPresented: $showEditSheet) { + editMessage + } - Button(action: { + Button { showDeleteAlert = true - }, label: { + } label: { ButtonContent(title: R.string.localizable.deleteMessage(), icon: "trash.fill") .foregroundColor(.red) - }) - .alert(R.string.localizable.confirmDeletionTitle(), isPresented: $showDeleteAlert) { - Button(R.string.localizable.confirm(), role: .destructive) { - viewModel.isLoading = true - Task(priority: .userInitiated) { - let success: Bool - let tempMessage = message.value - if message.value is AnswerMessage { - success = await viewModel.deleteAnswerMessage(messageId: message.value?.id) - } else { - success = await viewModel.deleteMessage(messageId: message.value?.id) - } - viewModel.isLoading = false - if success { - dismiss() - // if we deleted a Message and are in the MessageDetailView we pop it - if navigationController.path.count == 3 && tempMessage is Message { - navigationController.path.removeLast() - } + } + .alert(R.string.localizable.confirmDeletionTitle(), isPresented: $showDeleteAlert) { + Button(R.string.localizable.confirm(), role: .destructive) { + viewModel.isLoading = true + Task(priority: .userInitiated) { + let success: Bool + let tempMessage = message.value + if message.value is AnswerMessage { + success = await viewModel.deleteAnswerMessage(messageId: message.value?.id) + } else { + success = await viewModel.deleteMessage(messageId: message.value?.id) + } + viewModel.isLoading = false + if success { + dismiss() + // if we deleted a Message and are in the MessageDetailView we pop it + if navigationController.path.count == 3 && tempMessage is Message { + navigationController.path.removeLast() } } } - Button(R.string.localizable.cancel(), role: .cancel) { } } + Button(R.string.localizable.cancel(), role: .cancel) { } + } + } + } + } + + var editMessage: some View { + NavigationView { + Group { + if let course = viewModel.course.value, + let conversation = viewModel.conversation.value { + if let message = message.value as? Message { + SendMessageView( + viewModel: SendMessageViewModel( + course: course, + conversation: conversation, + configuration: .editMessage(message, { self.dismiss() }), + delegate: SendMessageViewModelDelegate(viewModel) + ) + ) + } else if let answerMessage = message.value as? AnswerMessage { + SendMessageView( + viewModel: SendMessageViewModel( + course: course, + conversation: conversation, + configuration: .editAnswerMessage(answerMessage, { self.dismiss() }), + delegate: SendMessageViewModelDelegate(viewModel) + ) + ) + } else { + Text(R.string.localizable.loading()) + } + } + } + .navigationTitle(R.string.localizable.editMessage()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(R.string.localizable.cancel()) { + showEditSheet = false + } + } } } + .presentationDetents([.height(200), .medium]) } } @@ -166,8 +199,8 @@ private struct ButtonContent: View { Text(title) .font(.headline) } - .padding(.horizontal, .l) - .foregroundColor(.Artemis.primaryLabel) + .padding(.horizontal, .l) + .foregroundColor(.Artemis.primaryLabel) } } @@ -233,7 +266,9 @@ private struct EmojiPickerButton: View { @State var selectedEmoji: Emoji? var body: some View { - Button(action: { showEmojiPicker = true }, label: { + Button { + showEmojiPicker = true + } label: { Image("face-smile", bundle: .module) .renderingMode(.template) .resizable() @@ -242,43 +277,43 @@ private struct EmojiPickerButton: View { .frame(width: .smallImage, height: .smallImage) .padding(20) .background(Capsule().fill(Color.Artemis.reactionCapsuleColor)) - }) - .sheet(isPresented: $showEmojiPicker) { - NavigationView { - EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: Color.Artemis.artemisBlue) - .navigationTitle(R.string.localizable.emojis()) - .navigationBarTitleDisplayMode(.inline) - } + } + .sheet(isPresented: $showEmojiPicker) { + NavigationView { + EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: Color.Artemis.artemisBlue) + .navigationTitle(R.string.localizable.emojis()) + .navigationBarTitleDisplayMode(.inline) } - .onChange(of: selectedEmoji) { _, newEmoji in - if let newEmoji, - let emojiId = Smile.alias(emoji: newEmoji.value) { - Task { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } + } + .onChange(of: selectedEmoji) { _, newEmoji in + if let newEmoji, + let emojiId = Smile.alias(emoji: newEmoji.value) { + Task { + if let message = message.value as? Message { + let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) + } + } else if let answerMessage = message.value as? AnswerMessage { + let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) } - selectedEmoji = nil - dismiss() } + selectedEmoji = nil + dismiss() } } + } } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index da650689..ec277021 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -5,24 +5,23 @@ // Created by Sven Andabaka on 12.04.23. // -import SwiftUI import ArtemisMarkdown -import SharedModels -import Navigation import Common -import UserStore import DesignLibrary +import Navigation +import SharedModels +import SwiftUI +import UserStore struct MessageCell: View { - @EnvironmentObject var navigationController: NavigationController @ObservedObject var viewModel: ConversationViewModel @Binding var message: DataState - @State private var showMessageActionSheet = false - @State private var isPressed = false + @State private var isActionSheetPresented = false + @State private var isDetectingLongPress = false var author: String { message.value?.author?.name ?? "" @@ -34,36 +33,37 @@ struct MessageCell: View { message.value?.content ?? "" } + var user: () -> User? = { UserSession.shared.user } + let conversationPath: ConversationPath? - let showHeader: Bool + let isHeaderVisible: Bool var body: some View { - HStack(alignment: .top, spacing: .l) { + HStack(alignment: .top, spacing: .m) { Image(systemName: "person") .resizable() .scaledToFit() - .frame(width: 30, height: 30) + .frame(width: 40, height: isHeaderVisible ? 40 : 0) .padding(.top, .s) - .opacity(showHeader ? 1 : 0) - VStack(alignment: .leading, spacing: .m) { - if showHeader { - HStack(alignment: .bottom, spacing: .m) { + VStack(alignment: .leading, spacing: .xs) { + if isHeaderVisible { + HStack(alignment: .firstTextBaseline, spacing: .m) { Text(author) .bold() if let creationDate { Text(creationDate, formatter: DateFormatter.timeOnly) .font(.caption) - if let lastReadDate = conversationPath?.conversation?.baseConversation.lastReadDate, - lastReadDate < creationDate, - UserSession.shared.user?.id != message.value?.author?.id { - Chip(text: R.string.localizable.new(), - backgroundColor: .Artemis.artemisBlue, - padding: .s) - .font(.footnote) - } + Chip( + text: R.string.localizable.new(), + backgroundColor: .Artemis.artemisBlue, + padding: .s + ) + .font(.footnote) + .opacity(isChipVisible(creationDate: creationDate) ? 1 : 0) } } } + ArtemisMarkdownView(string: content) if message.value?.updatedDate != nil { @@ -72,51 +72,93 @@ struct MessageCell: View { .font(.footnote) } - ReactionsView(viewModel: viewModel, message: $message, showEmojiAddButton: false) + ReactionsView(viewModel: viewModel, message: $message) + if let message = message.value as? Message, - let answerCount = message.answers?.count, - let conversationPath, - answerCount > 0 { - Button(R.string.localizable.replyAction(answerCount)) { - if let messagePath = MessagePath(message: self.$message, coursePath: conversationPath.coursePath, conversationPath: conversationPath, conversationViewModel: viewModel) { + let answerCount = message.answers?.count, answerCount > 0, + let conversationPath { + Button("^[\(answerCount) \(R.string.localizable.reply())](inflect: true)") { + if let messagePath = MessagePath( + message: self.$message, + coursePath: conversationPath.coursePath, + conversationPath: conversationPath, + conversationViewModel: viewModel + ) { navigationController.path.append(messagePath) } else { viewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } } } - }.id(message.value?.id.description) - Spacer() - } - .padding(.horizontal, .l) - .contentShape(Rectangle()) - .background(isPressed ? Color.Artemis.messsageCellPressed : Color.clear) - .onTapGesture { - if let conversationPath, - let messagePath = MessagePath(message: $message, - coursePath: conversationPath.coursePath, - conversationPath: conversationPath, - conversationViewModel: viewModel) { - navigationController.path.append(messagePath) - } } - .onLongPressGesture(minimumDuration: 0.1, maximumDistance: 30, perform: { - guard let conversation = viewModel.conversation.value else { return } - if let channel = conversation.baseConversation as? Channel, - channel.isArchived ?? false { - return - } - - let impactMed = UIImpactFeedbackGenerator(style: .heavy) - impactMed.impactOccurred() - showMessageActionSheet = true - isPressed = false - }, onPressingChanged: { pressed in - isPressed = pressed - }) - .sheet(isPresented: $showMessageActionSheet) { - MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: conversationPath) - .presentationDetents([.height(350), .large]) + .background { + RoundedRectangle(cornerRadius: .m) + .foregroundStyle( + (isDetectingLongPress || isActionSheetPresented) ? Color.Artemis.messsageCellPressed : Color.clear) } + .id(message.value?.id.description) + } + .padding(.horizontal, .l) + .contentShape(.rect) + .onTapGesture(perform: onTapPresentMessage) + .onLongPressGesture(perform: onLongPressPresentActionSheet) { changed in + isDetectingLongPress = changed + } + .sheet(isPresented: $isActionSheetPresented) { + MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: conversationPath) + .presentationDetents([.height(350), .large]) + } } } + +private extension MessageCell { + func isChipVisible(creationDate: Date) -> Bool { + guard let lastReadDate = conversationPath?.conversation?.baseConversation.lastReadDate else { + return false + } + + return lastReadDate < creationDate && user()?.id != message.value?.author?.id + } + + // MARK: Gestures + + func onTapPresentMessage() { + // Tap is disabled, if conversation path is nil, e.g., in the message detail view. + if let conversationPath, let messagePath = MessagePath( + message: $message, + coursePath: conversationPath.coursePath, + conversationPath: conversationPath, + conversationViewModel: viewModel + ) { + navigationController.path.append(messagePath) + } + } + + func onLongPressPresentActionSheet() { + guard let conversation = viewModel.conversation.value else { + return + } + if let channel = conversation.baseConversation as? Channel, channel.isArchived ?? false { + return + } + + let impactMed = UIImpactFeedbackGenerator(style: .heavy) + impactMed.impactOccurred() + isActionSheetPresented = true + isDetectingLongPress = false + } +} + +#Preview { + MessageCell( + viewModel: ConversationViewModel( + course: MessagesServiceStub.course, + conversation: MessagesServiceStub.conversation), + message: Binding.constant(DataState.done(response: MessagesServiceStub.message)), + conversationPath: ConversationPath( + conversation: MessagesServiceStub.conversation, + coursePath: CoursePath(course: MessagesServiceStub.course) + ), + isHeaderVisible: true + ) +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 90f93f08..56b91a74 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -5,140 +5,119 @@ // Created by Sven Andabaka on 08.04.23. // -import SwiftUI -import SharedModels import ArtemisMarkdown -import Navigation -import DesignLibrary import Common +import DesignLibrary +import Navigation +import SharedModels +import SwiftUI -// swiftlint:disable:next identifier_name -private let MAX_MINUTES_FOR_GROUPING_MESSAGES = 5 - -public struct MessageDetailView: View { +struct MessageDetailView: View { @ObservedObject var viewModel: ConversationViewModel @Binding private var message: DataState - @State private var showMessageActionSheet = false + @State private var isMessageActionSheetPresented = false @State private var viewRerenderWorkaround = false private let messageId: Int64? @State private var internalMessage: BaseMessage? - public init(viewModel: ConversationViewModel, - message: Binding>) { + init(viewModel: ConversationViewModel, message: Binding>) { self.viewModel = viewModel self.messageId = message.wrappedValue.value?.id self._message = message } - public init(viewModel: ConversationViewModel, - messageId: Int64) { - self.viewModel = viewModel - self.messageId = messageId - self._message = Binding(get: { .loading }, set: { _ in return }) - self.internalMessage = nil - - self._message = Binding(get: { [self] in - if let internalMessage = self.internalMessage { - return .done(response: internalMessage) - } - return .loading - }, set: { [self] in - if let message = $0.value as? Message { - self.internalMessage = message - } - }) - } - - public var body: some View { - DataStateView(data: $message, retryHandler: { await reloadMessage() }) { message in + var body: some View { + DataStateView(data: $message) { + await reloadMessage() + } content: { message in VStack(alignment: .leading) { - ScrollViewReader { value in + ScrollViewReader { proxy in ScrollView { - VStack(alignment: .leading) { - HStack(alignment: .top, spacing: .l) { - Image(systemName: "person") - .resizable() - .scaledToFit() - .frame(width: 30, height: 30) - .padding(.top, .s) - VStack(alignment: .leading, spacing: .m) { - Text(message.author?.name ?? "") - .bold() - if let creationDate = message.creationDate { - Text(creationDate, formatter: DateFormatter.timeOnly) - .font(.caption) - } - } - Spacer() - } - - ArtemisMarkdownView(string: message.content ?? "") - - if let updatedDate = message.updatedDate { - Text("\(R.string.localizable.edited()) (\(updatedDate.shortDateAndTime))") - .foregroundColor(.Artemis.secondaryLabel) - .font(.footnote) - } - - ReactionsView(viewModel: viewModel, message: $message) - } - .padding(.horizontal, .l) - .contentShape(Rectangle()) - .onLongPressGesture(maximumDistance: 30) { - let impactMed = UIImpactFeedbackGenerator(style: .heavy) - impactMed.impactOccurred() - showMessageActionSheet = true - } - .sheet(isPresented: $showMessageActionSheet) { - MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: nil) - .presentationDetents([.height(350), .large]) - } - if let message = message as? Message { - Divider() - VStack { - let sortedArray = (message.answers ?? []).sorted(by: { $0.creationDate ?? .tomorrow < $1.creationDate ?? .yesterday }) - ForEach(Array(sortedArray.enumerated()), id: \.1) { index, answerMessage in - MessageCellWrapper(viewModel: viewModel, - answerMessage: answerMessage, - showHeader: (index == 0 ? true : shouldShowHeader(message: answerMessage, previousMessage: sortedArray[index - 1]))) - } - Spacer() - .id("bottom") - .onAppear { - value.scrollTo("bottom", anchor: .bottom) - } - .onChange(of: message.answers) { - withAnimation { - if let id = viewModel.shouldScrollToId { - value.scrollTo(id, anchor: .bottom) - } - } - } - }.padding(.horizontal, .l) - } + top(message: message) + answers(of: message, proxy: proxy) } } Spacer() if !((viewModel.conversation.value?.baseConversation as? Channel)?.isArchived ?? false), - let message = message as? Message { - SendMessageView(viewModel: viewModel, sendMessageType: .answerMessage(message, { await reloadMessage() })) + let message = message as? Message, + let course = viewModel.course.value, + let conversation = viewModel.conversation.value { + SendMessageView( + viewModel: SendMessageViewModel( + course: course, + conversation: conversation, + configuration: .answerMessage(message, reloadMessage), + delegate: SendMessageViewModelDelegate(viewModel) + ) + ) } } } - .navigationTitle(R.string.localizable.thread()) - .task { - if message.value == nil { - await reloadMessage() + .navigationTitle(R.string.localizable.thread()) + .task { + if message.value == nil { + await reloadMessage() + } + } + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + } +} + +private extension MessageDetailView { + func top(message: BaseMessage) -> some View { + MessageCell( + viewModel: viewModel, + message: Binding>.constant(DataState.done(response: message)), + conversationPath: nil, + isHeaderVisible: true + ) + .environment(\.isEmojiPickerButtonVisible, true) + .onLongPressGesture(maximumDistance: 30) { + let impactMed = UIImpactFeedbackGenerator(style: .heavy) + impactMed.impactOccurred() + isMessageActionSheetPresented = true + } + .sheet(isPresented: $isMessageActionSheetPresented) { + MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: nil) + .presentationDetents([.height(350), .large]) + } + } + + @ViewBuilder + func answers(of message: BaseMessage, proxy: ScrollViewProxy) -> some View { + if let message = message as? Message { + Divider() + VStack { + let sortedArray = (message.answers ?? []).sorted { + $0.creationDate ?? .tomorrow < $1.creationDate ?? .yesterday } + ForEach(Array(sortedArray.enumerated()), id: \.1) { index, answerMessage in + MessageCellWrapper( + viewModel: viewModel, + answerMessage: answerMessage, + isHeaderVisible: index == 0 || !answerMessage.isContinuation(of: sortedArray[index - 1])) + } + Spacer() + .id("bottom") + .onAppear { + proxy.scrollTo("bottom", anchor: .bottom) + } + .onChange(of: message.answers) { + withAnimation { + if let id = viewModel.shouldScrollToId { + proxy.scrollTo(id, anchor: .bottom) + } + } + } } - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + } } - private func reloadMessage() async { + func reloadMessage() async { viewModel.shouldScrollToId = "bottom" guard let messageId else { return } let result = await viewModel.loadMessage(messageId: messageId) @@ -152,12 +131,6 @@ public struct MessageDetailView: View { viewRerenderWorkaround.toggle() } } - - // header is not shown if same person messages multiple times within 5 minutes - private func shouldShowHeader(message: AnswerMessage, previousMessage: AnswerMessage) -> Bool { - !(message.author == previousMessage.author && - message.creationDate ?? .now < (previousMessage.creationDate ?? .yesterday).addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60))) - } } private struct MessageCellWrapper: View { @@ -165,31 +138,46 @@ private struct MessageCellWrapper: View { @ObservedObject var viewModel: ConversationViewModel let answerMessage: AnswerMessage - let showHeader: Bool + let isHeaderVisible: Bool private var answerMessageBinding: Binding> { - Binding(get: { - if let keys = viewModel.dailyMessages.value?.keys { - let answerMessage: AnswerMessage? = keys.compactMap { key in - if let messageIndex = viewModel.dailyMessages.value?[key]?.firstIndex(where: { $0.answers?.contains(where: { $0.id == self.answerMessage.id }) ?? false }), - let answerMessage = viewModel.dailyMessages.value?[key]?[messageIndex].answers?.first(where: { $0.id == self.answerMessage.id }) { + + let isAnswerMessage = { (answer: AnswerMessage) -> Bool in + answer.id == self.answerMessage.id + } + let messageContainsAnswer = { (message: Message) -> Bool in + message.answers?.contains(where: isAnswerMessage) ?? false + } + + return Binding(get: { + if let dailyMessages = viewModel.dailyMessages.value { + let answerMessages: [AnswerMessage] = dailyMessages.keys.compactMap { key in + + if let messages = dailyMessages[key], + let messageIndex = messages.firstIndex(where: messageContainsAnswer), + let answerMessage = messages[messageIndex].answers?.first(where: isAnswerMessage) { return answerMessage } return nil - }.first - if let answerMessage { + } + + if let answerMessage = answerMessages.first { return .done(response: answerMessage) } } return .loading }, set: { newValue in - if let keys = viewModel.dailyMessages.value?.keys { - keys.forEach { key in - if let messageIndex = viewModel.dailyMessages.value?[key]?.firstIndex(where: { $0.answers?.contains(where: { $0.id == answerMessage.id }) ?? false }), - let answerMessageIndex = viewModel.dailyMessages.value?[key]?[messageIndex].answers?.firstIndex(where: { $0.id == answerMessage.id }), - let newAnswerMessage = newValue.value as? AnswerMessage { + if let newAnswerMessage = newValue.value as? AnswerMessage, + let dailyMessages = viewModel.dailyMessages.value { + + for key in dailyMessages.keys { + + if let messages = dailyMessages[key], + let messageIndex = messages.firstIndex(where: messageContainsAnswer), + let answerMessageIndex = messages[messageIndex].answers?.firstIndex(where: isAnswerMessage) { + viewModel.dailyMessages.value?[key]?[messageIndex].answers?[answerMessageIndex] = newAnswerMessage - return + continue } } } @@ -197,9 +185,26 @@ private struct MessageCellWrapper: View { } var body: some View { - MessageCell(viewModel: viewModel, - message: answerMessageBinding, - conversationPath: nil, - showHeader: showHeader) + MessageCell( + viewModel: viewModel, + message: answerMessageBinding, + conversationPath: nil, + isHeaderVisible: isHeaderVisible) } } + +#Preview { + MessageDetailView( + viewModel: { + let viewModel = ConversationViewModel( + course: MessagesServiceStub.course, + conversation: MessagesServiceStub.conversation) + viewModel.dailyMessages = .done(response: [ + MessagesServiceStub.now: [ + MessagesServiceStub.message + ] + ]) + return viewModel + }(), + message: Binding.constant(DataState.done(response: MessagesServiceStub.message))) +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift index cb43db0d..916cb0a3 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift @@ -5,21 +5,22 @@ // Created by Sven Andabaka on 08.04.23. // -import SwiftUI +import Common +import EmojiPicker import SharedModels import Smile +import SwiftUI import UserStore -import EmojiPicker -import Common struct ReactionsView: View { + @Environment(\.isEmojiPickerButtonVisible) var isEmojiPickerButtonVisible: Bool @ObservedObject private var viewModel: ConversationViewModel @Binding var message: DataState - let showEmojiAddButton: Bool @State private var viewRerenderWorkaround = false + let columns = [ GridItem(.adaptive(minimum: 45)) ] var mappedReaction: [String: [Reaction]] { @@ -38,18 +39,20 @@ struct ReactionsView: View { return reactions } - init(viewModel: ConversationViewModel, message: Binding>, showEmojiAddButton: Bool = true) { + init( + viewModel: ConversationViewModel, + message: Binding> + ) { self.viewModel = viewModel self._message = message - self.showEmojiAddButton = showEmojiAddButton } var body: some View { - LazyVGrid(columns: columns) { + LazyVGrid(columns: columns, alignment: .leading) { ForEach(mappedReaction.sorted(by: { $0.key < $1.key }), id: \.key) { map in EmojiTextButton(viewModel: viewModel, pair: (map.key, map.value), message: $message) } - if !mappedReaction.isEmpty || showEmojiAddButton { + if !mappedReaction.isEmpty || isEmojiPickerButtonVisible { EmojiPickerButton(viewModel: viewModel, message: $message, viewRerenderWorkaround: $viewRerenderWorkaround) } } @@ -64,57 +67,66 @@ private struct EmojiTextButton: View { @Binding var message: DataState var body: some View { - Text("\(pair.0) \(pair.1.count)") - .font(.caption) - .foregroundColor(isMyReaction ? Color.Artemis.artemisBlue : Color.Artemis.primaryLabel) - .frame(height: .extraSmallImage) - .padding(.m) - .background( - Group { - if isMyReaction { - Capsule() - .strokeBorder(Color.Artemis.artemisBlue, lineWidth: 1) - .background(Capsule().foregroundColor(Color.Artemis.artemisBlue.opacity(0.25))) - } else { - Capsule().fill(Color.Artemis.reactionCapsuleColor) - } + Button { + if let emojiId = Smile.alias(emoji: pair.0) { + Task { + await addReaction(emojiId: emojiId) } - ) - .onTapGesture { - if let emojiId = Smile.alias(emoji: pair.0) { - Task { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } + } + } label: { + Text("\(pair.0) \(pair.1.count)") + .font(.caption) + .foregroundColor(isMyReaction ? Color.Artemis.artemisBlue : Color.Artemis.primaryLabel) + .frame(height: .extraSmallImage) + .padding(.m) + .background( + Group { + if isMyReaction { + Capsule() + .strokeBorder(Color.Artemis.artemisBlue, lineWidth: 1) + .background(Capsule().foregroundColor(Color.Artemis.artemisBlue.opacity(0.25))) + } else { + Capsule() + .fill(Color.Artemis.reactionCapsuleColor) } } - } + ) + } + } +} + +private extension EmojiTextButton { + func addReaction(emojiId: String) async { + if let message = message.value as? Message { + let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) } + } else if let answerMessage = message.value as? AnswerMessage { + let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) + } + } } - private var isMyReaction: Bool { - if let emojiId = Smile.alias(emoji: pair.0), - let message = message.value { - return message.containsReactionFromMe(emojiId: emojiId) + var isMyReaction: Bool { + guard let emojiId = Smile.alias(emoji: pair.0), + let message = message.value else { + return false } - return false + + return message.containsReactionFromMe(emojiId: emojiId) } } @@ -122,14 +134,16 @@ private struct EmojiPickerButton: View { @ObservedObject var viewModel: ConversationViewModel - @State private var showEmojiPicker = false + @State private var isEmojiPickerPresented = false @State var selectedEmoji: Emoji? @Binding var message: DataState @Binding var viewRerenderWorkaround: Bool var body: some View { - Button(action: { showEmojiPicker = true }, label: { + Button { + isEmojiPickerPresented = true + } label: { Image("face-smile", bundle: .module) .renderingMode(.template) .resizable() @@ -138,43 +152,75 @@ private struct EmojiPickerButton: View { .frame(height: .extraSmallImage) .padding(.m) .background(Capsule().fill(Color.Artemis.reactionCapsuleColor)) - }) - .sheet(isPresented: $showEmojiPicker) { - NavigationView { - EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: Color.Artemis.artemisBlue) - .navigationTitle(R.string.localizable.emojis()) - .navigationBarTitleDisplayMode(.inline) - } + } + .sheet(isPresented: $isEmojiPickerPresented) { + NavigationView { + EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: Color.Artemis.artemisBlue) + .navigationTitle(R.string.localizable.emojis()) + .navigationBarTitleDisplayMode(.inline) } - .onChange(of: selectedEmoji) { _, newEmoji in - if let newEmoji, - let emojiId = Smile.alias(emoji: newEmoji.value) { - Task { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } - viewRerenderWorkaround.toggle() - selectedEmoji = nil - } + } + .onChange(of: selectedEmoji) { _, newEmoji in + if let newEmoji, + let emojiId = Smile.alias(emoji: newEmoji.value) { + Task { + await addReaction(emojiId: emojiId) + viewRerenderWorkaround.toggle() + selectedEmoji = nil } } + } + } +} + +private extension EmojiPickerButton { + func addReaction(emojiId: String) async { + if let message = message.value as? Message { + let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) + } + } else if let answerMessage = message.value as? AnswerMessage { + let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) + } + } } } + +// MARK: - Environment+IsEmojiPickerVisible + +private enum IsEmojiPickerVisibleEnvironmentKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var isEmojiPickerButtonVisible: Bool { + get { + self[IsEmojiPickerVisibleEnvironmentKey.self] + } + set { + self[IsEmojiPickerVisibleEnvironmentKey.self] = newValue + } + } +} + +#Preview { + ReactionsView( + viewModel: ConversationViewModel( + course: MessagesServiceStub.course, + conversation: MessagesServiceStub.conversation), + message: Binding.constant(DataState.done(response: MessagesServiceStub.message)) + ) +} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift index 88326427..7af7e522 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift @@ -5,8 +5,8 @@ // Created by Nityananda Zbil on 15.10.23. // +import ArtemisMarkdown import DesignLibrary -import MarkdownUI import SharedModels import SwiftUI @@ -23,7 +23,7 @@ struct CodeOfConductView: View { await viewModel.getCodeOfConductInformation() } content: { _ in VStack(alignment: .leading) { - Markdown(codeOfConductSanitized() + "\n" + responsibleUserMarkdown()) + ArtemisMarkdownView(string: codeOfConductSanitized() + "\n" + responsibleUserMarkdown()) // Take all available horizontal space HStack { Spacer() diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageView.swift deleted file mode 100644 index 595fcff8..00000000 --- a/ArtemisKit/Sources/Messages/Views/SendMessageView.swift +++ /dev/null @@ -1,289 +0,0 @@ -// -// SendMessageView.swift -// -// -// Created by Sven Andabaka on 08.04.23. -// - -import SwiftUI -import DesignLibrary -import Common -import SharedModels - -enum SendMessageType { - case message, answerMessage(Message, () async -> Void), editMessage(Message, () -> Void), editAnswerMessage(AnswerMessage, () -> Void) -} - -struct SendMessageView: View { - - @ObservedObject var viewModel: ConversationViewModel - - @State private var responseText = "" - @State private var showExercisePicker = false - @State private var showLecturePicker = false - - @FocusState private var isFocused: Bool - - let sendMessageType: SendMessageType - - var isEditMode: Bool { - switch sendMessageType { - case .message: - return false - case .answerMessage: - return false - case .editMessage: - return true - case .editAnswerMessage: - return true - } - } - - var body: some View { - VStack { - if isFocused && !isEditMode { - Capsule() - .fill(Color.secondary) - .frame(width: 50, height: 3) - .padding(.top, .m) - } - HStack(alignment: .bottom) { - textField - .lineLimit(10) - .focused($isFocused) - .toolbar { - ToolbarItem(placement: .keyboard) { - keyboardToolbarContent - } - } - if !isFocused { - sendButton - } - } - .padding(.horizontal, .l) - .padding(.bottom, .l) - .padding(.top, isFocused ? .m : .l) - } - .onAppear { - if case .editMessage(let message, _) = sendMessageType { - responseText = message.content ?? "" - } - if case .editAnswerMessage(let answerMessage, _) = sendMessageType { - responseText = answerMessage.content ?? "" - } - } - .overlay( - Group { - if isEditMode { - EmptyView() - } else { - RoundedRectangle(cornerRadius: 20) - .trim(from: isFocused ? 0.52 : 0.51, to: isFocused ? 0.98 : 0.99) - .stroke(Color.Artemis.artemisBlue, lineWidth: 2) - } - } - ) - .gesture( - DragGesture(minimumDistance: 30, coordinateSpace: .local) - .onEnded({ value in - if value.translation.height > 0 { - // down - isFocused = false - let impactMed = UIImpactFeedbackGenerator(style: .medium) - impactMed.impactOccurred() - } - }) - ) - } - - var textField: some View { - Group { - if isEditMode { - TextField(R.string.localizable.messageAction(viewModel.conversation.value?.baseConversation.conversationName ?? ""), - text: $responseText, axis: .vertical) - .textFieldStyle(ArtemisTextField()) - } else { - TextField(R.string.localizable.messageAction(viewModel.conversation.value?.baseConversation.conversationName ?? ""), - text: $responseText, axis: .vertical) - } - } - } - - var keyboardToolbarContent: some View { - HStack { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Button(action: { - responseText.append("****") - }, label: { - Image(systemName: "bold") - }) - Button(action: { - responseText.append("**") - }, label: { - Image(systemName: "italic") - }) - Button(action: { - responseText.append("") - }, label: { - Image(systemName: "underline") - }) - Button(action: { - responseText.append("> Reference") - }, label: { - Image(systemName: "quote.opening") - }) - Button(action: { - responseText.append("``") - }, label: { - Image(systemName: "curlybraces") - }) - Button(action: { - responseText.append("```java\nSource Code\n```") - }, label: { - Image(systemName: "curlybraces.square.fill") - }) - Button(action: { - responseText.append("[](http://)") - }, label: { - Image(systemName: "link") - }) - Button(action: { - isFocused = false - showExercisePicker = true - }, label: { - Text(R.string.localizable.exercise()) - }) - .sheet(isPresented: $showExercisePicker, onDismiss: { isFocused = true }) { - if let course = viewModel.course.value { - SendMessageExercisePicker(text: $responseText, course: course) - } else { - Text(R.string.localizable.loading()) - } - } - Button(action: { - isFocused = false - showLecturePicker = true - }, label: { - Text(R.string.localizable.lecture()) - }) - .sheet(isPresented: $showLecturePicker, onDismiss: { isFocused = true }) { - if let course = viewModel.course.value { - SendMessageLecturePicker(text: $responseText, course: course) - } else { - Text(R.string.localizable.loading()) - } - } - } - } - Spacer() - sendButton - } - } - - var sendButton: some View { - Button(action: { - viewModel.isLoading = true - Task { - var result: NetworkResponse? - switch sendMessageType { - case .message: - result = await viewModel.sendMessage(text: responseText) - case let .answerMessage(message, completion): - result = await viewModel.sendAnswerMessage(text: responseText, for: message, completion: completion) - case let .editMessage(message, completion): - var newmessage = message - newmessage.content = responseText - let success = await viewModel.editMessage(message: newmessage) - viewModel.isLoading = false - if success { - completion() - } - case let .editAnswerMessage(message, completion): - var newmessage = message - newmessage.content = responseText - let success = await viewModel.editAnswerMessage(answerMessage: newmessage) - viewModel.isLoading = false - if success { - completion() - } - } - switch result { - case .success: - responseText = "" - default: - return - } - } - }, label: { - Image(systemName: "paperplane.fill") - .imageScale(.large) - }) - .padding(.leading, .l) - .disabled(responseText.isEmpty) - .loadingIndicator(isLoading: $viewModel.isLoading) - } -} - -private struct SendMessageExercisePicker: View { - - @Environment(\.dismiss) var dismiss - - @Binding var text: String - - let course: Course - - var body: some View { - List(course.exercises ?? []) { exercise in - if let title = exercise.baseExercise.title { - Button(title) { - appendMarkdown(for: exercise) - dismiss() - } - } - } - } - - func appendMarkdown(for exercise: Exercise) { - let type: String? - switch exercise { - case .fileUpload: - type = "file-upload" - case .modeling: - type = "modeling" - case .programming: - type = "programming" - case .quiz: - type = "quiz" - case .text: - type = "text" - case .unknown: - type = nil - } - - guard let type, - let title = exercise.baseExercise.title else { return } - - text.append("[\(type)]\(title)(/courses/\(course.id)/exercises/\(exercise.id))[/\(type)]") - } -} - -private struct SendMessageLecturePicker: View { - - @Environment(\.dismiss) var dismiss - - @Binding var text: String - - let course: Course - - var body: some View { - List(course.lectures ?? [], id: \.id) { lecture in - if let title = lecture.title { - Button(title) { - text.append("[lecture]\(title)(/courses/\(course.id)/lectures/\(lecture.id))[/lecture]") - dismiss() - } - } - } - } -} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift new file mode 100644 index 00000000..4cccd8c1 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift @@ -0,0 +1,59 @@ +// +// SendMessageExercisePicker.swift +// +// +// Created by Nityananda Zbil on 29.10.23. +// + +import SharedModels +import SwiftUI + +struct SendMessageExercisePicker: View { + + @Environment(\.dismiss) var dismiss + + @Binding var text: String + + let course: Course + + var body: some View { + if let exercises = course.exercises, !exercises.isEmpty { + List(exercises) { exercise in + if let title = exercise.baseExercise.title { + Button(title) { + appendMarkdown(for: exercise) + dismiss() + } + } + } + } else { + ContentUnavailableView(R.string.localizable.exercisesUnavailable(), systemImage: "magnifyingglass") + } + } +} + +private extension SendMessageExercisePicker { + func appendMarkdown(for exercise: Exercise) { + let type: String? + switch exercise { + case .fileUpload: + type = "file-upload" + case .modeling: + type = "modeling" + case .programming: + type = "programming" + case .quiz: + type = "quiz" + case .text: + type = "text" + case .unknown: + type = nil + } + + guard let type, let title = exercise.baseExercise.title else { + return + } + + text.append("[\(type)]\(title)(/courses/\(course.id)/exercises/\(exercise.id))[/\(type)]") + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift new file mode 100644 index 00000000..237b3861 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift @@ -0,0 +1,33 @@ +// +// SendMessageLecturePicker.swift +// +// +// Created by Nityananda Zbil on 29.10.23. +// + +import SharedModels +import SwiftUI + +struct SendMessageLecturePicker: View { + + @Environment(\.dismiss) var dismiss + + @Binding var text: String + + let course: Course + + var body: some View { + if let lectures = course.lectures, !lectures.isEmpty { + List(lectures) { lecture in + if let title = lecture.title { + Button(title) { + text.append("[lecture]\(title)(/courses/\(course.id)/lectures/\(lecture.id))[/lecture]") + dismiss() + } + } + } + } else { + ContentUnavailableView(R.string.localizable.lecturesUnavailable(), systemImage: "magnifyingglass") + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionChannelView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionChannelView.swift new file mode 100644 index 00000000..147b1b0c --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionChannelView.swift @@ -0,0 +1,58 @@ +// +// SendMessageMentionChannelView.swift +// +// +// Created by Nityananda Zbil on 02.12.23. +// + +import DesignLibrary +import SwiftUI + +struct SendMessageMentionChannelView: View { + + @State var viewModel: SendMessageMentionChannelViewModel + + @Bindable var sendMessageViewModel: SendMessageViewModel + + var body: some View { + HStack { + Spacer() + DataStateView(data: $viewModel.channels) { + if let candidate = sendMessageViewModel.searchChannel().map(String.init) { + await viewModel.search(idOrName: candidate) + } + } content: { channels in + if !channels.isEmpty { + List { + ForEach(channels) { channel in + Button(channel.name) { + sendMessageViewModel.replace(channel: channel) + } + } + } + } else { + ContentUnavailableView(R.string.localizable.channelsUnavailable(), systemImage: "magnifyingglass") + } + } + .onChange(of: sendMessageViewModel.text, initial: true, search) + Spacer() + } + .listStyle(.plain) + .clipShape(.rect(cornerRadius: .l)) + .overlay { + RoundedRectangle(cornerRadius: .l) + .stroke(Color.Artemis.artemisBlue, lineWidth: 2) + } + .padding(.bottom, .m) + } +} + +private extension SendMessageMentionChannelView { + func search() { + if let candidate = sendMessageViewModel.searchChannel().map(String.init) { + Task { + await viewModel.search(idOrName: candidate) + } + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionMemberView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionMemberView.swift new file mode 100644 index 00000000..5df78635 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionMemberView.swift @@ -0,0 +1,58 @@ +// +// SendMessageMentionMemberView.swift +// +// +// Created by Nityananda Zbil on 28.10.23. +// + +import DesignLibrary +import SwiftUI + +struct SendMessageMentionMemberView: View { + + @State var viewModel: SendMessageMentionMemberViewModel + + @Bindable var sendMessageViewModel: SendMessageViewModel + + var body: some View { + HStack { + Spacer() + DataStateView(data: $viewModel.members) { + if let candidate = sendMessageViewModel.searchMember().map(String.init) { + await viewModel.search(loginOrName: candidate) + } + } content: { members in + if !members.isEmpty { + List { + ForEach(members, id: \.login) { member in + Button(member.name ?? "") { + sendMessageViewModel.replace(member: member) + } + } + } + } else { + ContentUnavailableView(R.string.localizable.membersUnavailable(), systemImage: "magnifyingglass") + } + } + .onChange(of: sendMessageViewModel.text, initial: true, search) + Spacer() + } + .listStyle(.plain) + .clipShape(.rect(cornerRadius: .l)) + .overlay { + RoundedRectangle(cornerRadius: .l) + .stroke(Color.Artemis.artemisBlue, lineWidth: 2) + } + .padding(.bottom, .m) + } +} + +private extension SendMessageMentionMemberView { + func search() { + if let candidate = sendMessageViewModel.searchMember().map(String.init) { + Task { + await viewModel.search(loginOrName: candidate) + } + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift new file mode 100644 index 00000000..dffcfed5 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -0,0 +1,173 @@ +// +// SendMessageView.swift +// +// +// Created by Sven Andabaka on 08.04.23. +// + +import Common +import DesignLibrary +import SharedModels +import SwiftUI + +struct SendMessageView: View { + + @State var viewModel: SendMessageViewModel + + @FocusState private var isFocused: Bool + + var body: some View { + VStack { + mentions + VStack { + if isFocused && !viewModel.isEditing { + Capsule() + .fill(Color.secondary) + .frame(width: 50, height: 3) + .padding(.top, .m) + } + HStack(alignment: .bottom) { + textField + .lineLimit(10) + .focused($isFocused) + .toolbar { + ToolbarItem(placement: .keyboard) { + keyboardToolbarContent + } + } + if !isFocused { + sendButton + } + } + .padding(.horizontal, .l) + .padding(.bottom, .l) + .padding(.top, isFocused ? .m : .l) + } + .onAppear { + viewModel.performOnAppear() + } + .onDisappear { + viewModel.performOnDisappear() + } + .overlay { + if viewModel.isEditing { + EmptyView() + } else { + RoundedRectangle(cornerRadius: .m) + .trim(from: isFocused ? 0.52 : 0.51, to: isFocused ? 0.98 : 0.99) + .stroke(Color.Artemis.artemisBlue, lineWidth: 2) + } + } + .gesture( + DragGesture(minimumDistance: 30, coordinateSpace: .local) + .onEnded { value in + if value.translation.height > 0 { + // down + isFocused = false + let impactMed = UIImpactFeedbackGenerator(style: .medium) + impactMed.impactOccurred() + } + } + ) + } + .sheet(item: $viewModel.modalPresentation) { + isFocused = true + } content: { presentation in + switch presentation { + case .exercisePicker: + SendMessageExercisePicker(text: $viewModel.text, course: viewModel.course) + case .lecturePicker: + SendMessageLecturePicker(text: $viewModel.text, course: viewModel.course) + } + } + } +} + +private extension SendMessageView { + @ViewBuilder var mentions: some View { + switch viewModel.conditionalPresentation { + case .memberPicker: + SendMessageMentionMemberView( + viewModel: SendMessageMentionMemberViewModel(course: viewModel.course), + sendMessageViewModel: viewModel + ) + case .channelPicker: + SendMessageMentionChannelView( + viewModel: SendMessageMentionChannelViewModel(course: viewModel.course), + sendMessageViewModel: viewModel + ) + case nil: + EmptyView() + } + } + + @ViewBuilder var textField: some View { + let label = R.string.localizable.messageAction(viewModel.conversation.baseConversation.conversationName) + if viewModel.isEditing { + TextField(label, text: $viewModel.text, axis: .vertical) + .textFieldStyle(ArtemisTextField()) + } else { + TextField(label, text: $viewModel.text, axis: .vertical) + } + } + + var keyboardToolbarContent: some View { + HStack { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Button(action: viewModel.didTapBoldButton) { + Image(systemName: "bold") + } + Button(action: viewModel.didTapItalicButton) { + Image(systemName: "italic") + } + Button(action: viewModel.didTapUnderlineButton) { + Image(systemName: "underline") + } + Button(action: viewModel.didTapBlockquoteButton) { + Image(systemName: "quote.opening") + } + Button(action: viewModel.didTapCodeButton) { + Image(systemName: "curlybraces") + } + Button(action: viewModel.didTapCodeBlockButton) { + Image(systemName: "curlybraces.square.fill") + } + Button(action: viewModel.didTapLinkButton) { + Image(systemName: "link") + } + Button(action: viewModel.didTapAtButton) { + Image(systemName: "at") + } + Button(action: viewModel.didTapNumberButton) { + Image(systemName: "number") + } + Button { + isFocused = false + viewModel.modalPresentation = .exercisePicker + } label: { + Text(R.string.localizable.exercise()) + } + Button { + isFocused = false + viewModel.modalPresentation = .lecturePicker + } label: { + Text(R.string.localizable.lecture()) + } + } + } + Spacer() + sendButton + } + } + + var sendButton: some View { + Button(action: viewModel.didTapSendButton) { + Image(systemName: "paperplane.fill") + .imageScale(.large) + } + .padding(.leading, .l) + .disabled(viewModel.text.isEmpty) + .loadingIndicator(isLoading: $viewModel.isLoading) + } +} diff --git a/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift index 6676b03b..e64184fb 100644 --- a/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift +++ b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift @@ -20,27 +20,42 @@ public class DeeplinkHandler { var navigationController: NavigationController? - private init() { } + private let userSession: UserSession + + private init( + userSession: UserSession = .shared + ) { + self.userSession = userSession + } func setup(navigationController: NavigationController) { self.navigationController = navigationController } public func handle(path: String) { - guard let url = URL(string: path, relativeTo: UserSession.shared.institution?.baseURL) else { return } + guard let url = URL(string: path, relativeTo: userSession.institution?.baseURL) else { + return + } handle(url: url) } - public func handle(url: URL) { - guard let navigationController else { - return + /// - Returns: Whether a handler could handle the URL. + @discardableResult + public func handle(url: URL) -> Bool { + guard url.host() == userSession.institution?.baseURL?.host(), + let navigationController, + let handler = buildHandler(from: url) else { + return false } - buildHandler(from: url)?.handle(with: navigationController) + + handler.handle(with: navigationController) + + return true } private func buildHandler(from url: URL) -> Deeplink? { - // warning: the order of the array matters - let builderFuncs: [(URL) -> Deeplink?] = [ + // Attention: the order of the array matters + let builders: [(URL) -> Deeplink?] = [ ExerciseHandler.build, LectureHandler.build, MessageHandler.build, @@ -50,20 +65,10 @@ public class DeeplinkHandler { UnknownLinkHandler.build ] - return builderFuncs - .map { $0(url) } - .compactMap { $0 } + return builders + .compactMap { builder in + builder(url) + } .first } } - -extension URL { - func trimBaseUrl() -> String? { - let string = self.absoluteString - - guard let baseURL = UserSession.shared.institution?.baseURL, - let endIndex = string.range(of: baseURL.absoluteString)?.upperBound else { return nil } - - return String(string.suffix(from: endIndex)) - } -} diff --git a/ArtemisKit/Sources/Navigation/NavigationController.swift b/ArtemisKit/Sources/Navigation/NavigationController.swift index c4b1b98e..60949689 100644 --- a/ArtemisKit/Sources/Navigation/NavigationController.swift +++ b/ArtemisKit/Sources/Navigation/NavigationController.swift @@ -1,6 +1,5 @@ -import SwiftUI -import SharedModels import Common +import SwiftUI @MainActor public class NavigationController: ObservableObject { @@ -18,157 +17,50 @@ public class NavigationController: ObservableObject { DeeplinkHandler.shared.setup(navigationController: self) } +} - public func popToRoot() { +public extension NavigationController { + func popToRoot() { path = NavigationPath() } - public func goToCourse(id: Int) { + func goToCourse(id: Int) { popToRoot() path.append(CoursePath(id: id)) log.debug("CoursePath was appended to queue") } - public func goToExercise(courseId: Int, exerciseId: Int) { + func goToExercise(courseId: Int, exerciseId: Int) { courseTab = .exercise goToCourse(id: courseId) - path.append(ExercisePath(id: exerciseId, - coursePath: CoursePath(id: courseId))) + path.append(ExercisePath(id: exerciseId, coursePath: CoursePath(id: courseId))) log.debug("ExercisePath was appended to queue") } - public func goToLecture(courseId: Int, lectureId: Int) { + func goToLecture(courseId: Int, lectureId: Int) { courseTab = .lecture goToCourse(id: courseId) - path.append(LecturePath(id: lectureId, - coursePath: CoursePath(id: courseId))) + path.append(LecturePath(id: lectureId, coursePath: CoursePath(id: courseId))) log.debug("LecturePath was appended to queue") } - public func setTab(identifier: TabIdentifier) { + func setTab(identifier: TabIdentifier) { courseTab = identifier } - public func goToCourseConversations(courseId: Int) { + func goToCourseConversations(courseId: Int) { courseTab = .communication goToCourse(id: courseId) } - public func goToCourseConversation(courseId: Int, conversationId: Int64) { + func goToCourseConversation(courseId: Int, conversationId: Int64) { goToCourseConversations(courseId: courseId) - path.append(ConversationPath(id: conversationId, - coursePath: CoursePath(id: courseId))) + path.append(ConversationPath(id: conversationId, coursePath: CoursePath(id: courseId))) } - public func showDeeplinkNotSupported(url: URL) { + func showDeeplinkNotSupported(url: URL) { notSupportedUrl = url showDeeplinkNotSupported = true } } - -public enum TabIdentifier { - case exercise, lecture, communication -} - -public struct CoursePath: Hashable { - public let id: Int - public let course: Course? - - public init(id: Int) { - self.id = id - self.course = nil - } - - public init(course: Course) { - self.id = course.id - self.course = course - } -} - -public struct ExercisePath: Hashable { - public let id: Int - public let exercise: Exercise? - public let coursePath: CoursePath - - init(id: Int, coursePath: CoursePath) { - self.id = id - self.exercise = nil - self.coursePath = coursePath - } - - public init(exercise: Exercise, coursePath: CoursePath) { - self.id = exercise.id - self.exercise = exercise - self.coursePath = coursePath - } -} - -public struct LecturePath: Hashable { - public let id: Int - public let lecture: Lecture? - public let coursePath: CoursePath - - init(id: Int, coursePath: CoursePath) { - self.id = id - self.lecture = nil - self.coursePath = coursePath - } - - public init(lecture: Lecture, coursePath: CoursePath) { - self.id = lecture.id - self.lecture = lecture - self.coursePath = coursePath - } -} - -public struct ConversationPath: Hashable { - public let id: Int64 - public let conversation: Conversation? - public let coursePath: CoursePath - - public init(id: Int64, coursePath: CoursePath) { - self.id = id - self.conversation = nil - self.coursePath = coursePath - } - - public init(conversation: Conversation, coursePath: CoursePath) { - self.id = conversation.id - self.conversation = conversation - self.coursePath = coursePath - } -} - -public struct MessagePath: Hashable { - public let id: Int64 - public let message: Binding>? - public let coursePath: CoursePath - public let conversationPath: ConversationPath - public let conversationViewModel: Any? - - init(id: Int64, coursePath: CoursePath, conversationPath: ConversationPath) { - self.id = id - self.message = nil - self.coursePath = coursePath - self.conversationPath = conversationPath - self.conversationViewModel = nil - } - - public init?(message: Binding>, coursePath: CoursePath, conversationPath: ConversationPath, conversationViewModel: Any) { - guard let id = message.wrappedValue.value?.id else { return nil } - self.id = id - self.message = message - self.coursePath = coursePath - self.conversationPath = conversationPath - self.conversationViewModel = conversationViewModel - } - - public static func == (lhs: MessagePath, rhs: MessagePath) -> Bool { - lhs.id == rhs.id && lhs.coursePath == rhs.coursePath && lhs.conversationPath == rhs.conversationPath - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} diff --git a/ArtemisKit/Sources/Navigation/NavigationPathValues.swift b/ArtemisKit/Sources/Navigation/NavigationPathValues.swift new file mode 100644 index 00000000..ae5cc4e6 --- /dev/null +++ b/ArtemisKit/Sources/Navigation/NavigationPathValues.swift @@ -0,0 +1,77 @@ +// +// NavigationPathValues.swift +// +// +// Created by Nityananda Zbil on 26.02.24. +// + +import SharedModels + +public struct CoursePath: Hashable { + public let id: Int + public let course: Course? + + public init(id: Int) { + self.id = id + self.course = nil + } + + public init(course: Course) { + self.id = course.id + self.course = course + } +} + +public struct ExercisePath: Hashable { + public let id: Int + public let exercise: Exercise? + public let coursePath: CoursePath + + init(id: Int, coursePath: CoursePath) { + self.id = id + self.exercise = nil + self.coursePath = coursePath + } + + public init(exercise: Exercise, coursePath: CoursePath) { + self.id = exercise.id + self.exercise = exercise + self.coursePath = coursePath + } +} + +public struct LecturePath: Hashable { + public let id: Int + public let lecture: Lecture? + public let coursePath: CoursePath + + init(id: Int, coursePath: CoursePath) { + self.id = id + self.lecture = nil + self.coursePath = coursePath + } + + public init(lecture: Lecture, coursePath: CoursePath) { + self.id = lecture.id + self.lecture = lecture + self.coursePath = coursePath + } +} + +public struct ConversationPath: Hashable { + public let id: Int64 + public let conversation: Conversation? + public let coursePath: CoursePath + + public init(id: Int64, coursePath: CoursePath) { + self.id = id + self.conversation = nil + self.coursePath = coursePath + } + + public init(conversation: Conversation, coursePath: CoursePath) { + self.id = conversation.id + self.conversation = conversation + self.coursePath = coursePath + } +} diff --git a/ArtemisKit/Sources/Navigation/TabIdentifier.swift b/ArtemisKit/Sources/Navigation/TabIdentifier.swift new file mode 100644 index 00000000..d98e0ea2 --- /dev/null +++ b/ArtemisKit/Sources/Navigation/TabIdentifier.swift @@ -0,0 +1,12 @@ +// +// TabIdentifier.swift +// +// +// Created by Nityananda Zbil on 26.02.24. +// + +public enum TabIdentifier { + case exercise + case lecture + case communication +} diff --git a/ArtemisKit/Tests/ArtemisKitTests/Messages/MessagesRepositoryTests.swift b/ArtemisKit/Tests/ArtemisKitTests/Messages/MessagesRepositoryTests.swift new file mode 100644 index 00000000..a3e8f5c1 --- /dev/null +++ b/ArtemisKit/Tests/ArtemisKitTests/Messages/MessagesRepositoryTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import Messages + +final class MessagesRepositoryTests: XCTestCase { + func testInsertAndUpdateAndFetch() async throws { + // given + let url = try XCTUnwrap(URL(string: "https://example.org")) + let host = try XCTUnwrap(url.host()) + let courseId = 1 + let conversationId = 1 + let messageDraft = "Hello" + let messageDraftUpdate = "Hello, world!" + + // when + // - init + let repository = try await MessagesRepository() + + await repository.insertServer(host: host) + + // - insert & update + try await repository.insertConversation(host: host, courseId: courseId, conversationId: conversationId, messageDraft: messageDraft) + try await repository.insertConversation(host: host, courseId: courseId, conversationId: conversationId, messageDraft: messageDraftUpdate) + + // - fetch + let conversation = try await repository.fetchConversation(host: host, courseId: courseId, conversationId: conversationId) + + // then + let first = try XCTUnwrap(conversation) + XCTAssertEqual(first.messageDraft, messageDraftUpdate) + } +} diff --git a/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageChannelPickerViewModelTests.swift b/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageChannelPickerViewModelTests.swift new file mode 100644 index 00000000..8f8a21f2 --- /dev/null +++ b/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageChannelPickerViewModelTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import Messages + +final class SendMessageChannelPickerViewModelTests: XCTestCase { + func testChannelNameCaseInsensitivity() async throws { + // given + let viewModel = SendMessageMentionChannelViewModel( + course: .init(id: 1, courseInformationSharingConfiguration: .communicationAndMessaging), + messagesService: MessagesServiceStub()) + + // when + await viewModel.search(idOrName: "Annôunce") + + // then + let channels = try XCTUnwrap(viewModel.channels.value) + XCTAssertNotNil(channels.first) + } +} diff --git a/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageViewModelTests.swift b/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageViewModelTests.swift new file mode 100644 index 00000000..3170be46 --- /dev/null +++ b/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageViewModelTests.swift @@ -0,0 +1,88 @@ +import XCTest +import SharedModels +@testable import Messages + +final class SendMessageViewModelTests: XCTestCase { + func makeViewModel() -> SendMessageViewModel { + SendMessageViewModel( + course: Course(id: 1, courseInformationSharingConfiguration: .communicationAndMessaging), + conversation: Conversation(conversation: Channel(id: 1))!, + configuration: .message, + delegate: SendMessageViewModelDelegate( + loadMessages: {}, + presentError: { _ in }, + scrollToId: { _ in })) + } + + func testWriteAt() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "@" + + // then + XCTAssertEqual(viewModel.conditionalPresentation, .memberPicker) + } + + func testWriteNumber() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "#" + + // then + XCTAssertEqual(viewModel.conditionalPresentation, .channelPicker) + } + + func testSuppressAt() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "@" + viewModel.isMemberPickerSuppressed = true + + // then + XCTAssertNotEqual(viewModel.conditionalPresentation, .memberPicker) + } + + func testSuppressNumber() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "#" + viewModel.isChannelPickerSuppressed = true + + // then + XCTAssertNotEqual(viewModel.conditionalPresentation, .channelPicker) + } + + func testOverrideAt() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "@user " + viewModel.text += "#channel" + viewModel.isMemberPickerSuppressed = true + + // then + XCTAssertNotEqual(viewModel.conditionalPresentation, .memberPicker) + } + + func testOverrideNumber() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "#channel " + viewModel.text += "@user" + viewModel.isChannelPickerSuppressed = true + + // then + XCTAssertNotEqual(viewModel.conditionalPresentation, .channelPicker) + } +}