From cc6389e2b32346d97836cb92c83a8f7a844da578 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 01:38:39 +0100 Subject: [PATCH 01/25] Bump rexml from 3.3.8 to 3.3.9 (#200) Bumps [rexml](https://github.com/ruby/rexml) from 3.3.8 to 3.3.9. - [Release notes](https://github.com/ruby/rexml/releases) - [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md) - [Commits](https://github.com/ruby/rexml/compare/v3.3.8...v3.3.9) --- updated-dependencies: - dependency-name: rexml dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Anian Schleyer <98647423+anian03@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 950b4cd5..5efd3592 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -171,7 +171,7 @@ GEM trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.3.8) + rexml (3.3.9) rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) From b95f67929934f6c55dd650d583b8635ee24fe874 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 8 Nov 2024 22:41:30 +0100 Subject: [PATCH 02/25] `Communication`: Support navigating to FAQ from message (#199) * Support navigating to FAQ from message * Increment version number * Resolve runtime warnings --- Artemis/Supporting/Info.plist | 2 +- ArtemisKit/Package.swift | 1 + .../ConversationViewModels/ConversationViewModel.swift | 1 - .../MessageCellModel+MentionScheme.swift | 8 ++++++++ .../Views/ConversationView/ConversationView.swift | 1 - .../Messages/Views/MessageDetailView/MessageCell.swift | 3 +++ .../Views/MessagesTabView/MessagesAvailableView.swift | 4 ++++ 7 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Artemis/Supporting/Info.plist b/Artemis/Supporting/Info.plist index e4302c21..901218a5 100644 --- a/Artemis/Supporting/Info.plist +++ b/Artemis/Supporting/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.1 + 1.5.0 CFBundleVersion 1 ITSAppUsesNonExemptEncryption diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index a59ecb11..da1f29d7 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -110,6 +110,7 @@ let package = Package( name: "Messages", dependencies: [ "Extensions", + "Faq", "Navigation", .product(name: "EmojiPicker", package: "EmojiPicker"), .product(name: "APIClient", package: "artemis-ios-core-modules"), diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index 5f637994..f1155c1a 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -13,7 +13,6 @@ import SharedModels import SharedServices import UserStore -@MainActor class ConversationViewModel: BaseViewModel { let course: Course diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift index 6a1ba6ca..80db3aae 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift @@ -16,6 +16,7 @@ enum MentionScheme { case member(login: String) case message(id: Int64) case slide(number: Int, attachmentUnit: Int) + case faq(id: Int64) init?(_ url: URL) { guard url.scheme == "mention" else { @@ -64,6 +65,13 @@ enum MentionScheme { self = .slide(number: id, attachmentUnit: attachmentUnit) return } + case "faq": + // E.g., mention://faq/faqId=20 + if let idString = url.absoluteString.split(separator: "=").last, + let id = Int64(idString) { + self = .faq(id: id) + return + } default: return nil } diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index bd378345..f5835132 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -12,7 +12,6 @@ import Navigation import SharedModels import SwiftUI -@MainActor public struct ConversationView: View { @EnvironmentObject var navigationController: NavigationController diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 1ecbedbb..d2aadc57 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -11,6 +11,7 @@ import DesignLibrary import Navigation import SharedModels import SwiftUI +import Faq struct MessageCell: View { @Environment(\.isMessageOffline) var isMessageOffline: Bool @@ -364,6 +365,8 @@ private extension MessageCell { return } } + case let .faq(id): + navigationController.tabPath.append(FaqPath(id: id, courseId: viewModel.course.id)) } return .handled } diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index aa053c04..25daa424 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -7,6 +7,7 @@ import Common import DesignLibrary +import Faq import Navigation import SharedModels import SwiftUI @@ -189,6 +190,9 @@ public struct MessagesAvailableView: View { } } .modifier(NavigationDestinationMessagesModifier()) + .navigationDestination(for: FaqPath.self) { path in + FaqPathView(path: path) + } } } .toolbar(.hidden, for: .navigationBar) From 2f7c32f27a7491c68118d65cd709a0850b2720f7 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 9 Nov 2024 23:23:20 +0100 Subject: [PATCH 03/25] `Communication`: Show profile pictures in channel list (#208) --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- ArtemisKit/Package.swift | 2 +- .../Messages/Views/ConversationView/ConversationView.swift | 2 -- .../Views/CreateConversationViews/BrowseChannelsView.swift | 1 - .../Views/MessageDetailView/MessageDetailView.swift | 1 - .../MessagesTabView/ConversationRow/ConversationRow.swift | 1 - .../Services/NotificationWebsocketServiceImpl.swift | 2 +- 7 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ed6a521..4cff713e 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "abe60a35389b3a48746220c8c769e40edfe597e7cfc1f2c27bfcbd88959c19bb", + "originHash" : "5efde1acc1a55b9d310c6b32ff1dbfb7a10bb2f1d3e9fc50636c2ee486b2a7ab", "pins" : [ { "identity" : "apollon-ios-module", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "3d5a7856b07222645ef9bc0b472236083bf6c3e3", - "version" : "14.7.1" + "revision" : "346950fb2d5263c5f4fbecd5f4c4f6579b4d1669", + "version" : "15.0.0" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index da1f29d7..44f39dae 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -20,7 +20,7 @@ let package = Package( dependencies: [ .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: "14.7.1")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "15.0.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.7.0") ], targets: [ diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index f5835132..36dbd279 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -97,8 +97,6 @@ public struct ConversationView: View { } label: { HStack(alignment: .center, spacing: .m) { viewModel.conversation.baseConversation.icon? - .renderingMode(.template) - .resizable() .scaledToFit() .frame(height: 20) VStack(alignment: .leading, spacing: .xxs) { diff --git a/ArtemisKit/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift b/ArtemisKit/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift index a855ccea..b5840e9c 100644 --- a/ArtemisKit/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift +++ b/ArtemisKit/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift @@ -75,7 +75,6 @@ private struct ChannelRow: View { HStack { if let icon = channel.icon { icon - .resizable() .scaledToFit() .frame(width: .extraSmallImage, height: .extraSmallImage) } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 6890b4ec..72703056 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -61,7 +61,6 @@ struct MessageDetailView: View { .fontWeight(.semibold) HStack(spacing: .s) { viewModel.conversation.baseConversation.icon? - .resizable() .scaledToFit() .frame(height: .m * 1.5) Text(viewModel.conversation.baseConversation.conversationName) diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift index 914d624b..3f0cf587 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift @@ -86,7 +86,6 @@ private extension ConversationRow { @ViewBuilder var conversationIcon: some View { if let icon = conversation.icon { icon - .resizable() .scaledToFit() .frame(height: .extraSmallImage) .frame(maxWidth: .infinity, alignment: .trailing) diff --git a/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift b/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift index 54478d1e..1e3a0bb6 100644 --- a/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift +++ b/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift @@ -160,7 +160,7 @@ class NotificationWebsocketServiceImpl: NotificationWebsocketService { for await message in stream { guard let quizExercise = JSONDecoder.getTypeFromSocketMessage(type: QuizExercise.self, message: message) else { continue } if quizExercise.visibleToStudents ?? false, - quizExercise.quizMode == .SYNCHRONIZED, + quizExercise.quizMode == .synchronized, quizExercise.quizBatches?.first?.started ?? false, !(quizExercise.isOpenForPractice ?? false) { guard let notification = Notification.createNotificationFromStartedQuizExercise(quizExercise: quizExercise) else { continue } From b88633b5e6d8a15005bb8ebe0b4e1985d31dbbc7 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sun, 10 Nov 2024 23:31:24 +0100 Subject: [PATCH 04/25] `Communication`: Add support for uploading images (#209) * Add upload image function to messages service * Update button label style * Add Upload Screen --- .../Resources/en.lproj/Localizable.strings | 2 + .../MessagesService/MessagesService.swift | 5 + .../MessagesService/MessagesServiceImpl.swift | 25 +++ .../MessagesService/MessagesServiceStub.swift | 4 + .../SendMessageUploadImageViewModel.swift | 152 ++++++++++++++++++ .../SendMessageViewModel.swift | 4 + .../SendMessageImagePickerView.swift | 114 +++++++++++++ .../SendMessageViews/SendMessageView.swift | 7 +- 8 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index c2b53483..ddecfbad 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -23,6 +23,7 @@ "codeBlock" = "Code block"; "code" = "Code"; "link" = "Link"; +"uploadImage" = "Upload Image"; // MARK: SendMessageMentionContentView "members" = "Members"; @@ -155,6 +156,7 @@ "ok" = "OK"; "loading" = "Loading..."; "cancel" = "Cancel"; +"uploading" = "Uploading"; "previous" = "Previous"; "next" = "Next"; "confirm" = "Confirm"; diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index 957a30c4..29da57f1 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -48,6 +48,11 @@ protocol MessagesService { */ func sendAnswerMessage(for courseId: Int, message: Message, content: String) async -> NetworkResponse + /** + * Perform a post request for uploading a jpeg image in a specific conversation to the server. + */ + func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState + /** * Perform a delete request for a message in a specific course to the server. */ diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift index d2747063..f3a098d9 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -229,6 +229,31 @@ struct MessagesServiceImpl: MessagesService { } } + struct UploadImageResult: Codable { + let path: String + } + + func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState { + if image.count > 5 * 1024 * 1024 { + return .failure(error: .init(title: "File too big to upload")) + } + + let request = MultipartFormDataRequest(path: "api/files/courses/\(courseId)/conversations/\(conversationId)") + request.addDataField(named: "file", + filename: "\(UUID().uuidString).jpg", + data: image, + mimeType: "image/jpeg") + + let result: Swift.Result<(UploadImageResult, Int), APIClientError> = await client.sendRequest(request) + + switch result { + case .success(let response): + return .done(response: response.0.path) + case .failure(let failure): + return .failure(error: .init(error: failure)) + } + } + struct DeleteMessageRequest: APIRequest { typealias Response = RawResponse diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift index 15219ef6..d3d709b3 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift @@ -204,4 +204,8 @@ extension MessagesServiceStub: MessagesService { func unarchiveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { .loading } + + func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState { + .loading + } } diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift new file mode 100644 index 00000000..a9d9e33f --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift @@ -0,0 +1,152 @@ +// +// File.swift +// ArtemisKit +// +// Created by Anian Schleyer on 09.11.24. +// + +import Common +import Foundation +import PhotosUI +import SwiftUI + +enum UploadState: Equatable { + case selectImage + case compressing + case uploading + case done + case failed(error: UserFacingError) +} + +@Observable +final class SendMessageUploadImageViewModel { + + let courseId: Int + let conversationId: Int64 + + var selection: PhotosPickerItem? + var image: UIImage? + var uploadState = UploadState.selectImage + var imagePath: String? + private var uploadTask: Task<(), Never>? + + var showUploadScreen: Binding { + .init { + self.uploadState != .selectImage + } set: { newValue in + if !newValue { + self.uploadState = .selectImage + } + } + } + var error: UserFacingError? { + switch uploadState { + case .failed(let error): + return error + default: + return nil + } + } + + var statusLabel: String { + switch uploadState { + case .selectImage: + "" + case .compressing: + R.string.localizable.loading() + case .uploading: + R.string.localizable.uploading() + case .done: + R.string.localizable.done() + case .failed(let error): + error.localizedDescription + } + } + + private let messagesService: MessagesService + + init( + courseId: Int, + conversationId: Int64, + messagesService: MessagesService = MessagesServiceFactory.shared + ) { + self.courseId = courseId + self.conversationId = conversationId + self.messagesService = messagesService + } + + /// Register as change handler for selection on View + func onChange() { + loadTransferable(from: selection) + } + + private func loadTransferable(from item: PhotosPickerItem?) { + guard let item else { + return + } + + uploadState = .compressing + imagePath = nil + + Task { + if let transferable = try? await item.loadTransferable(type: Data.self) { + image = UIImage(data: transferable) + upload(image: image) + } + } + } + + private func upload(image: UIImage?) { + guard let image else { return } + + guard let imageData = compressImageBelow5MB(image) else { + uploadState = .failed(error: .init(title: "Image too large. Plese select a smaller image.")) + return + } + + uploadState = .uploading + + uploadTask = Task { + let result = await messagesService.uploadImage(for: courseId, and: conversationId, image: imageData) + if Task.isCancelled { + return + } + + switch result { + case .loading: + break + case .failure(let error): + uploadState = .failed(error: error) + case .done(let response): + imagePath = response + uploadState = .done + } + selection = nil + } + } + + private func compressImageBelow5MB(_ image: UIImage, level: Double = 1) -> Data? { + guard let imageData = image.jpegData(compressionQuality: level) else { + return nil + } + + // Too much compression needed to be useful + if level < 0.3 { + return nil + } + + if imageData.count > 5 * 1024 * 1024 { + return compressImageBelow5MB(image, level: level - 0.2) + } else { + return imageData + } + } + + func cancel() { + uploadTask?.cancel() + uploadTask = nil + selection = nil + image = nil + uploadState = .selectImage + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index 25b9c933..4ff2c955 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -205,6 +205,10 @@ extension SendMessageViewModel { } } + func insertImageMention(path: String) { + appendToSelection(before: "![", after: "](\(path))", placeholder: "image") + } + /// Prepends/Appends the given snippets to text the user has selected. private func appendToSelection(before: String, after: String, placeholder: String) { let placeholderText = "\(before)\(placeholder)\(after)" diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift new file mode 100644 index 00000000..34563f53 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift @@ -0,0 +1,114 @@ +// +// SendMessageImagePickerView.swift +// ArtemisKit +// +// Created by Anian Schleyer on 09.11.24. +// + +import PhotosUI +import SwiftUI + +struct SendMessageImagePickerView: View { + + var sendViewModel: SendMessageViewModel + @State private var viewModel: SendMessageUploadImageViewModel + + init(sendMessageViewModel: SendMessageViewModel) { + self._viewModel = State(initialValue: .init(courseId: sendMessageViewModel.course.id, + conversationId: sendMessageViewModel.conversation.id)) + self.sendViewModel = sendMessageViewModel + } + + var body: some View { + PhotosPicker(selection: $viewModel.selection, + matching: .images, + preferredItemEncoding: .compatible) { + Label(R.string.localizable.uploadImage(), systemImage: "photo.fill") + } + .onChange(of: viewModel.selection) { + viewModel.onChange() + } + .sheet(isPresented: viewModel.showUploadScreen) { + if let path = viewModel.imagePath { + sendViewModel.insertImageMention(path: path) + } + viewModel.selection = nil + viewModel.image = nil + } content: { + UploadImageView(viewModel: viewModel) + } + } +} + +private struct UploadImageView: View { + var viewModel: SendMessageUploadImageViewModel + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack { + backgroundImage + + VStack { + statusIcon + + Text(viewModel.statusLabel) + .frame(maxWidth: 300) + .font(.title) + + if viewModel.uploadState == .uploading { + Button(R.string.localizable.cancel()) { + viewModel.cancel() + } + .buttonStyle(.bordered) + } + } + .animation(.smooth(duration: 0.2), value: viewModel.uploadState) + } + .interactiveDismissDisabled() + .onChange(of: viewModel.uploadState) { + if viewModel.uploadState == .done { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + dismiss() + } + } + if viewModel.error != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + dismiss() + } + } + } + } + + @ViewBuilder var statusIcon: some View { + Group { + if viewModel.uploadState != .done && viewModel.error == nil { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1.5) + } + if viewModel.uploadState == .done { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + if viewModel.error != nil { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } + } + .font(.largeTitle) + .frame(height: 60) + .transition(.blurReplace) + } + + @ViewBuilder var backgroundImage: some View { + if let image = viewModel.image { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .blur(radius: 10, opaque: true) + .opacity(0.2) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index 9d4732a7..6b38f7af 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -135,7 +135,6 @@ private extension SendMessageView { } } label: { Label(R.string.localizable.mention(), systemImage: "plus.circle.fill") - .labelStyle(.iconOnly) } Menu { Button { @@ -155,13 +154,11 @@ private extension SendMessageView { } } label: { Label(R.string.localizable.style(), systemImage: "bold.italic.underline") - .labelStyle(.iconOnly) } Button { viewModel.didTapBlockquoteButton() } label: { Label(R.string.localizable.quote(), systemImage: "quote.opening") - .labelStyle(.iconOnly) } Menu { Button { @@ -176,15 +173,15 @@ private extension SendMessageView { } } label: { Label(R.string.localizable.code(), systemImage: "curlybraces") - .labelStyle(.iconOnly) } Button { viewModel.didTapLinkButton() } label: { Label(R.string.localizable.link(), systemImage: "link") - .labelStyle(.iconOnly) } + SendMessageImagePickerView(sendMessageViewModel: viewModel) } + .labelStyle(.iconOnly) .font(.title3) } Spacer() From 1d7e462d873f04d7556091da5454c948dcf699b1 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Thu, 14 Nov 2024 21:36:33 +0100 Subject: [PATCH 05/25] `Lectures`: Fix lecture channel icon being distorted (#218) --- .../Sources/CourseView/LectureTab/LectureDetailView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift index 5146bd02..aabbabfc 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift @@ -105,7 +105,9 @@ private struct ChannelCell: View { .suffix(name.starts(with: "lecture-") ? name.count - 8 : name.count) Text(String(displayName)) } icon: { - channel.icon + channel.icon? + .scaledToFit() + .frame(height: 22) } .font(.title3) From 4c788e1a93135e626f3cf5252f049fd48296acec Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Thu, 14 Nov 2024 22:06:51 +0100 Subject: [PATCH 06/25] `Communication`: Fix messages disappearing after being sent (#219) --- .../MessagesService/MessagesServiceImpl.swift | 15 +++++++++++++-- .../ConversationViewModel.swift | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift index f3a098d9..0ea74bba 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -169,7 +169,7 @@ struct MessagesServiceImpl: MessagesService { } struct SendMessageRequest: APIRequest { - typealias Response = RawResponse + typealias Response = Message let courseId: Int let visibleForStudents: Bool @@ -192,7 +192,10 @@ struct MessagesServiceImpl: MessagesService { ) switch result { - case .success: + case .success(let response): + NotificationCenter.default.post(name: .newMessageSent, + object: nil, + userInfo: ["message": response.0]) return .success case let .failure(error): return .failure(error: error) @@ -925,3 +928,11 @@ private extension ConversationType { } } } + +// MARK: Reload Notification + +extension Foundation.Notification.Name { + // Sending a notification of this type causes the Notification List to be reloaded, + // when favorites are changed from elsewhere. + static let newMessageSent = Foundation.Notification.Name("NewMessageSent") +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index f1155c1a..b79d2ce0 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -330,6 +330,12 @@ private extension ConversationViewModel { } else { return } + + NotificationCenter.default.addObserver(self, + selector: #selector(onOwnMessageSent(notification:)), + name: .newMessageSent, + object: nil) + if stompClient.didSubscribeTopic(topic) { /// These web socket topics are the same across multiple channels. /// We might need to wait until a previously open conversation has unsubscribed @@ -392,6 +398,15 @@ private extension ConversationViewModel { } } + @objc + func onOwnMessageSent(notification: Foundation.Notification) { + if let message = notification.userInfo?["message"] as? Message { + DispatchQueue.main.async { + self.onMessageReceived(messageWebsocketDTO: .init(post: message, action: .create, notification: nil)) + } + } + } + func handle(new message: Message) { shouldScrollToId = message.id.description let (inserted, _) = messages.insert(.message(message)) From 561bee33cc0dd09db536597864d8bab3c6e397c2 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 15 Nov 2024 19:51:48 +0100 Subject: [PATCH 07/25] `Communication`: Improve member picker (#220) * Display warnings when too few characters typed or no matches * Add profile pictures to member picker * Select textfield automatically --- .../Resources/en.lproj/Localizable.strings | 2 + .../CreateOrAddToChatView.swift | 49 ++++++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index ddecfbad..00bd3e97 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -101,6 +101,8 @@ "announcementChannelDescription" = "Only instructors and channel moderators can create new messages in an announcement channel. Students can only read the messages and answer to them."; "createChannelButtonLabel" = "Create Channel"; "createChannelNavTitel" = "Create Channel"; +"noMatchingUsers" = "No matching users found"; +"enterAtLeast3Characters" = "Please enter at least three characters to start your search."; // MARK: BrowseChannelsView "joinedLabel" = "Joined"; diff --git a/ArtemisKit/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift b/ArtemisKit/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift index a9e1cf0c..190f6230 100644 --- a/ArtemisKit/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift +++ b/ArtemisKit/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift @@ -17,6 +17,7 @@ struct CreateOrAddToChatView: View { case addToChat(Conversation) } + @FocusState private var focused @Environment(\.dismiss) var dismiss @EnvironmentObject var navigationController: NavigationController @@ -30,6 +31,7 @@ struct CreateOrAddToChatView: View { selectedUsers TextField(R.string.localizable.searchUsersLabel(), text: $viewModel.searchText) .textFieldStyle(.roundedBorder) + .focused($focused) .padding(.horizontal, .l) searchResults } @@ -65,6 +67,9 @@ struct CreateOrAddToChatView: View { }.disabled(viewModel.selectedUsers.isEmpty) } } + .onAppear { + focused = true + } .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) } } @@ -95,11 +100,17 @@ private extension CreateOrAddToChatView { HStack { ForEach(viewModel.selectedUsers.reversed(), id: \.id) { user in if let name = user.name { - Button(role: .destructive) { + Button { viewModel.unstage(user: user) } label: { - Chip(text: name, backgroundColor: .Artemis.artemisBlue) - } + HStack { + ProfilePictureView(user: user, role: nil, course: .mock, size: 25) + .allowsHitTesting(false) + Text(name) + } + .padding(.m) + .background(Color.Artemis.artemisBlue, in: .rect(cornerRadius: .m)) + }.buttonStyle(.plain) } } } @@ -115,20 +126,32 @@ private extension CreateOrAddToChatView { DataStateView(data: $viewModel.searchResults) { await viewModel.loadUsers() } content: { users in - List { - ForEach( - users.filter({ user in !viewModel.selectedUsers.contains(where: { $0.id == user.id }) }), id: \.id - ) { user in - if let name = user.name { - Button { - viewModel.stage(user: user) - } label: { - Text(name) + if viewModel.searchText.count < 3 { + ContentUnavailableView(R.string.localizable.enterAtLeast3Characters(), + systemImage: "magnifyingglass") + } else { + List { + let displayedUsers = users.filter({ user in !viewModel.selectedUsers.contains(where: { $0.id == user.id }) }) + ForEach(displayedUsers, id: \.id) { user in + if let name = user.name { + Button { + viewModel.stage(user: user) + } label: { + HStack { + ProfilePictureView(user: user, role: nil, course: .mock, size: 25) + .allowsHitTesting(false) + Text(name) + } + } } } + if displayedUsers.isEmpty { + ContentUnavailableView(R.string.localizable.noMatchingUsers(), + systemImage: "person.slash.fill") + } } + .listStyle(.plain) } - .listStyle(.plain) } } } From 3d22541396e1525ff4b6c5cdda08f516d382a2f7 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 15 Nov 2024 21:28:55 +0100 Subject: [PATCH 08/25] `Communication`: Streamline socket connection handling (#221) --- .../Networking/SocketConnectionHandler.swift | 105 ++++++++++++++++++ .../ConversationViewModel.swift | 71 +++++------- .../MessagesAvailableViewModel.swift | 28 +++-- .../ConversationView/ConversationView.swift | 8 +- 4 files changed, 151 insertions(+), 61 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift diff --git a/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift b/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift new file mode 100644 index 00000000..49b3d750 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift @@ -0,0 +1,105 @@ +// +// SocketConnectionHandler.swift +// ArtemisKit +// +// Created by Anian Schleyer on 15.11.24. +// + +import APIClient +import Combine +import Foundation + +class SocketConnectionHandler { + private let stompClient = ArtemisStompClient.shared + let messagePublisher = PassthroughSubject() + let conversationPublisher = PassthroughSubject() + + private var channelSubscription: Task<(), Never>? + private var conversationSubscription: Task<(), Never>? + private var membershipSubscription: Task<(), Never>? + + static let shared = SocketConnectionHandler() + + private init() {} + + func cancelSubscriptions() { + channelSubscription?.cancel() + conversationSubscription?.cancel() + membershipSubscription?.cancel() + + channelSubscription = nil + conversationSubscription = nil + membershipSubscription = nil + } + + func subscribeToChannelNotifications(courseId: Int) { + guard channelSubscription == nil else { + return + } + + let topic = WebSocketTopic.makeChannelNotifications(courseId: courseId) + + channelSubscription = Task { [weak self] in + guard let self else { + return + } + + let stream = stompClient.subscribe(to: topic) + + for await message in stream { + guard let messageWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: MessageWebsocketDTO.self, message: message) else { + continue + } + print("Stomp channel") + + messagePublisher.send(messageWebsocketDTO) + } + } + } + + func subscribeToConversationNotifications(userId: Int64) { + guard conversationSubscription == nil else { + return + } + + let topic = WebSocketTopic.makeConversationNotifications(userId: userId) + + conversationSubscription = Task { [weak self] in + guard let self else { + return + } + + let stream = stompClient.subscribe(to: topic) + + for await message in stream { + guard let messageWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: MessageWebsocketDTO.self, message: message) else { + continue + } + print("Stomp convo") + messagePublisher.send(messageWebsocketDTO) + } + } + } + + func subscribeToMembershipNotifications(courseId: Int, userId: Int64) { + guard membershipSubscription == nil else { + return + } + + let topic = WebSocketTopic.makeConversationMembershipNotifications(courseId: courseId, userId: userId) + membershipSubscription = Task { [weak self] in + guard let self else { + return + } + + let stream = stompClient.subscribe(to: topic) + + for await message in stream { + guard let conversationWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: ConversationWebsocketDTO.self, message: message) else { + continue + } + conversationPublisher.send(conversationWebsocketDTO) + } + } + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index b79d2ce0..43b2b5e4 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -8,6 +8,7 @@ import APIClient import Foundation import Common +import Combine import Extensions import SharedModels import SharedServices @@ -45,11 +46,10 @@ class ConversationViewModel: BaseViewModel { } var shouldScrollToId: String? - var subscription: Task<(), Never>? + var subscription: AnyCancellable? fileprivate let messagesRepository: MessagesRepository private let messagesService: MessagesService - private let stompClient: ArtemisStompClient private let userSession: UserSession init( @@ -57,7 +57,6 @@ class ConversationViewModel: BaseViewModel { conversation: Conversation, messagesRepository: MessagesRepository? = nil, messagesService: MessagesService = MessagesServiceFactory.shared, - stompClient: ArtemisStompClient = .shared, userSession: UserSession = UserSessionFactory.shared ) { self.course = course @@ -65,7 +64,6 @@ class ConversationViewModel: BaseViewModel { self.messagesRepository = messagesRepository ?? .shared self.messagesService = messagesService - self.stompClient = stompClient self.userSession = userSession super.init() @@ -320,49 +318,28 @@ private extension ConversationViewModel { // MARK: Initializer func subscribeToConversationTopic() { - let topic: String + let socketConnection = SocketConnectionHandler.shared + subscription = socketConnection + .messagePublisher + .sink { [weak self] messageWebsocketDTO in + guard let self else { + return + } + onMessageReceived(messageWebsocketDTO: messageWebsocketDTO) + } + if conversation.baseConversation.type == .channel, let channel = conversation.baseConversation as? Channel, channel.isCourseWide == true { - topic = WebSocketTopic.makeChannelNotifications(courseId: course.id) + socketConnection.subscribeToChannelNotifications(courseId: course.id) } else if let id = userSession.user?.id { - topic = WebSocketTopic.makeConversationNotifications(userId: id) - } else { - return + socketConnection.subscribeToConversationNotifications(userId: id) } NotificationCenter.default.addObserver(self, selector: #selector(onOwnMessageSent(notification:)), name: .newMessageSent, object: nil) - - if stompClient.didSubscribeTopic(topic) { - /// These web socket topics are the same across multiple channels. - /// We might need to wait until a previously open conversation has unsubscribed - /// before we can subscribe again - Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in - DispatchQueue.main.async { [weak self] in - self?.subscribeToConversationTopic() - } - } - return - } - subscription = Task { [weak self] in - 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 - } - - guard let self else { - return - } - onMessageReceived(messageWebsocketDTO: messageWebsocketDTO) - } - } } func fetchOfflineMessages() { @@ -386,15 +363,17 @@ private extension ConversationViewModel { guard messageWebsocketDTO.post.conversation?.id == conversation.id else { return } - switch messageWebsocketDTO.action { - case .create: - handle(new: messageWebsocketDTO.post) - case .update: - handle(update: messageWebsocketDTO.post) - case .delete: - handle(delete: messageWebsocketDTO.post) - default: - return + DispatchQueue.main.async { + switch messageWebsocketDTO.action { + case .create: + self.handle(new: messageWebsocketDTO.post) + case .update: + self.handle(update: messageWebsocketDTO.post) + case .delete: + self.handle(delete: messageWebsocketDTO.post) + default: + return + } } } diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift index 0d723752..9e542829 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift @@ -6,6 +6,7 @@ // import APIClient +import Combine import Common import DesignLibrary import Foundation @@ -49,20 +50,19 @@ class MessagesAvailableViewModel: BaseViewModel { let courseId: Int private let messagesService: MessagesService - private let stompClient: ArtemisStompClient private let userSession: UserSession + private var subscription: AnyCancellable? + init( course: Course, messagesService: MessagesService = MessagesServiceFactory.shared, - stompClient: ArtemisStompClient = ArtemisStompClient.shared, userSession: UserSession = UserSessionFactory.shared ) { self.course = course self.courseId = course.id self.messagesService = messagesService - self.stompClient = stompClient self.userSession = userSession super.init() @@ -73,21 +73,29 @@ class MessagesAvailableViewModel: BaseViewModel { object: nil) } + deinit { + SocketConnectionHandler.shared.cancelSubscriptions() + subscription?.cancel() + } + func subscribeToConversationMembershipTopic() async { guard let userId = userSession.user?.id else { log.debug("User could not be found. Subscribe to Conversation not possible") return } - let topic = WebSocketTopic.makeConversationMembershipNotifications(courseId: courseId, userId: userId) - let stream = stompClient.subscribe(to: topic) + let socketConnection = SocketConnectionHandler.shared - for await message in stream { - guard let conversationWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: ConversationWebsocketDTO.self, message: message) else { - continue + subscription = socketConnection + .conversationPublisher + .sink { [weak self] conversationWebsocketDTO in + guard let self else { + return + } + onConversationMembershipMessageReceived(conversationWebsocketDTO: conversationWebsocketDTO) } - onConversationMembershipMessageReceived(conversationWebsocketDTO: conversationWebsocketDTO) - } + + socketConnection.subscribeToMembershipNotifications(courseId: courseId, userId: userId) } func loadConversations() async { diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index 36dbd279..a69026a2 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -136,11 +136,9 @@ public struct ConversationView: View { await viewModel.loadMessages() } .onDisappear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if navigationController.selectedCourse == nil { - // only cancel task if we navigate back - viewModel.subscription?.cancel() - } + if navigationController.courseTab != .communication && navigationController.tabPath.isEmpty { + // only cancel task if we leave communication + SocketConnectionHandler.shared.cancelSubscriptions() } viewModel.saveContext() } From 6958e074bc3f0ce932fe6a39d0a704959da51f3d Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 15 Nov 2024 21:41:16 +0100 Subject: [PATCH 09/25] `Communication`: Fix chat icon overlapping title in thread view (#222) --- .../Views/MessageDetailView/MessageDetailView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 72703056..f24a2d96 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -60,9 +60,15 @@ struct MessageDetailView: View { Text(R.string.localizable.thread()) .fontWeight(.semibold) HStack(spacing: .s) { - viewModel.conversation.baseConversation.icon? - .scaledToFit() - .frame(height: .m * 1.5) + ViewThatFits(in: .horizontal) { + viewModel.conversation.baseConversation.icon? + .scaledToFit() + .frame(height: .m * 1.5) + viewModel.conversation.baseConversation.icon? + .scaleEffect(0.5) + .frame(height: .m * 1.5) + } + .frame(maxWidth: 25) Text(viewModel.conversation.baseConversation.conversationName) } .font(.footnote) From 4940aec46ef8c3fcbe9327373983509a69d40d45 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 15 Nov 2024 21:52:29 +0100 Subject: [PATCH 10/25] `Communication`: Fix cut off channel names (#223) --- .../Messages/Views/MessageDetailView/MessageDetailView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index f24a2d96..dd844d92 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -69,7 +69,9 @@ struct MessageDetailView: View { .frame(height: .m * 1.5) } .frame(maxWidth: 25) - Text(viewModel.conversation.baseConversation.conversationName) + // Workaround: Trailing spaces, otherwise SwiftUI shortens this prematurely + Text(viewModel.conversation.baseConversation.conversationName + " ") + .frame(maxWidth: 220) } .font(.footnote) }.padding(.leading, .m) From e1cadf22577c54530966940589a2a157236c91ae Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 15 Nov 2024 23:53:05 +0100 Subject: [PATCH 11/25] `Communication`: Fix network error when opening chats (#224) * Fix error "cancelled" when opening chat * Add loading indicator for initial conversation push --- .../Messages/Navigation/PathViewModels.swift | 13 ++++++++++++- .../Sources/Messages/Navigation/PathViews.swift | 2 +- .../ConversationViewModel.swift | 9 +++++++++ .../Views/ConversationView/ConversationView.swift | 5 +---- ArtemisKit/Sources/Navigation/PathViewModels.swift | 13 +++++++++++++ ArtemisKit/Sources/Navigation/PathViews.swift | 2 +- 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Navigation/PathViewModels.swift b/ArtemisKit/Sources/Messages/Navigation/PathViewModels.swift index ff2d37a2..0f04dbdc 100644 --- a/ArtemisKit/Sources/Messages/Navigation/PathViewModels.swift +++ b/ArtemisKit/Sources/Messages/Navigation/PathViewModels.swift @@ -26,7 +26,7 @@ final class ConversationPathViewModel { self.messagesService = messagesService } - func loadConversation() async { + func reloadConversation() async { let result = await messagesService.getConversations(for: path.coursePath.id) self.conversation = result.flatMap { conversations in if let conversation = conversations.first(where: { $0.id == path.id }) { @@ -36,4 +36,15 @@ final class ConversationPathViewModel { } } } + + func loadConversation() async { + // If conversation is loaded already, skip + switch conversation { + case .done: + return + default: + break + } + await reloadConversation() + } } diff --git a/ArtemisKit/Sources/Messages/Navigation/PathViews.swift b/ArtemisKit/Sources/Messages/Navigation/PathViews.swift index d6360d4f..f075c522 100644 --- a/ArtemisKit/Sources/Messages/Navigation/PathViews.swift +++ b/ArtemisKit/Sources/Messages/Navigation/PathViews.swift @@ -17,7 +17,7 @@ public struct ConversationPathView: View { public var body: some View { DataStateView(data: $viewModel.conversation) { - await viewModel.loadConversation() + await viewModel.reloadConversation() } content: { conversation in CoursePathView(path: viewModel.path.coursePath) { course in content(course, conversation) diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index 43b2b5e4..ff227a9c 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -29,6 +29,7 @@ class ConversationViewModel: BaseViewModel { @Published var isConversationInfoSheetPresented = false @Published var selectedMessageId: Int64? + @Published var isLoadingMessages = true var isAllowedToPost: Bool { guard let channel = conversation.baseConversation as? Channel else { @@ -75,6 +76,10 @@ class ConversationViewModel: BaseViewModel { selector: #selector(updateFavorites(notification:)), name: .favoriteConversationChanged, object: nil) + + Task { + await loadMessages() + } } deinit { @@ -100,6 +105,10 @@ extension ConversationViewModel { } func loadMessages() async { + defer { + isLoadingMessages = false + } + let result = await messagesService.getMessages(for: course.id, and: conversation.id, page: page) switch result { case .loading: diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index a69026a2..1d91d740 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -39,6 +39,7 @@ public struct ConversationView: View { R.string.localizable.noMessages(), systemImage: "bubble.right", description: Text(R.string.localizable.noMessagesDescription())) + .loadingIndicator(isLoading: $viewModel.isLoadingMessages) } else { ScrollViewReader { value in ScrollView { @@ -131,10 +132,6 @@ public struct ConversationView: View { .sheet(isPresented: $viewModel.isConversationInfoSheetPresented) { ConversationInfoSheetView(course: viewModel.course, conversation: $viewModel.conversation) } - .task { - viewModel.shouldScrollToId = "bottom" - await viewModel.loadMessages() - } .onDisappear { if navigationController.courseTab != .communication && navigationController.tabPath.isEmpty { // only cancel task if we leave communication diff --git a/ArtemisKit/Sources/Navigation/PathViewModels.swift b/ArtemisKit/Sources/Navigation/PathViewModels.swift index 3dd47f5c..492a0446 100644 --- a/ArtemisKit/Sources/Navigation/PathViewModels.swift +++ b/ArtemisKit/Sources/Navigation/PathViewModels.swift @@ -24,7 +24,20 @@ final class CoursePathViewModel { self.courseService = courseService } + func reloadCourse() async { + let result = await courseService.getCourse(courseId: path.id) + self.course = result.map(\.course) + } + func loadCourse() async { + // If course is already loaded, skip this + switch course { + case .done: + return + default: + break + } + let start = Date().timeIntervalSince1970 let result = await courseService.getCourse(courseId: path.id) diff --git a/ArtemisKit/Sources/Navigation/PathViews.swift b/ArtemisKit/Sources/Navigation/PathViews.swift index a25f2357..0f2cea78 100644 --- a/ArtemisKit/Sources/Navigation/PathViews.swift +++ b/ArtemisKit/Sources/Navigation/PathViews.swift @@ -16,7 +16,7 @@ public struct CoursePathView: View { public var body: some View { DataStateView(data: $viewModel.course) { - await viewModel.loadCourse() + await viewModel.reloadCourse() } content: { course in content(course) } From 0fb5df0f37867cec535a731e416a79835dc03300 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:17:02 +0100 Subject: [PATCH 12/25] `Communication`: Remove edit button for instructors (#227) --- .../MessageDetailView/MessageActions.swift | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift index 591cc4dc..378daaba 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift @@ -95,7 +95,7 @@ struct MessageActions: View { @State private var showDeleteAlert = false @State private var showEditSheet = false - var isAbleToEditDelete: Bool { + var canDelete: Bool { guard let message = message.value else { return false } @@ -107,7 +107,19 @@ struct MessageActions: View { guard let channel = viewModel.conversation.baseConversation as? Channel else { return false } - if channel.hasChannelModerationRights ?? false && message is Message { + if channel.hasChannelModerationRights ?? false { + return true + } + + return false + } + + var canEdit: Bool { + guard let message = message.value else { + return false + } + + if message.isCurrentUserAuthor { return true } @@ -116,9 +128,10 @@ struct MessageActions: View { var body: some View { Group { - if isAbleToEditDelete { + if canEdit || canDelete { Divider() - + } + if canEdit { Button(R.string.localizable.editMessage(), systemImage: "pencil") { showEditSheet = true } @@ -128,7 +141,8 @@ struct MessageActions: View { editMessage .font(nil) } - + } + if canDelete { Button(R.string.localizable.deleteMessage(), systemImage: "trash", role: .destructive) { showDeleteAlert = true } From 803e4be498c98b1c485feeeeab9b8e334b3e2ccf Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:27:05 +0100 Subject: [PATCH 13/25] `Communication`: Add message filter options (#225) * Add filters in messages * Update placeholder when no messages match --- .../Models/MessageRequestFilter.swift | 99 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 6 ++ .../MessagesService/MessagesService.swift | 2 +- .../MessagesService/MessagesServiceImpl.swift | 7 +- .../MessagesService/MessagesServiceStub.swift | 2 +- .../ConversationViewModel.swift | 31 +++++- .../ConversationView/ConversationView.swift | 30 +++++- .../MessageDetailView/MessageDetailView.swift | 12 +++ 8 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/Models/MessageRequestFilter.swift diff --git a/ArtemisKit/Sources/Messages/Models/MessageRequestFilter.swift b/ArtemisKit/Sources/Messages/Models/MessageRequestFilter.swift new file mode 100644 index 00000000..dfab451c --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/MessageRequestFilter.swift @@ -0,0 +1,99 @@ +// +// MessageRequestFilter.swift +// ArtemisKit +// +// Created by Anian Schleyer on 16.11.24. +// + +import Foundation +import SharedModels +import SwiftUI +import UserStore + +class MessageRequestFilter: Codable { + var filters: [FilterOption] + + init(filterToUnresolved: Bool = false, + filterToOwn: Bool = false, + filterToAnsweredOrReacted: Bool = false) { + self.filters = [ + .init(name: .filterToUnresolved, enabled: filterToUnresolved), + .init(name: .filterToOwn, enabled: filterToOwn), + .init(name: .filterToAnsweredOrReacted, enabled: filterToAnsweredOrReacted) + ] + } + + var selectedFilter: String { + get { + self.filters.first { $0.enabled }?.name ?? "all" + } + set { + if newValue == "all" { + self.filters = self.filters.map { + FilterOption(name: $0.name, enabled: false) + } + } else { + self.filters = self.filters.map { + FilterOption(name: $0.name, enabled: $0.name == newValue) + } + } + } + } + + func messageMatchesSelectedFilter(_ message: Message) -> Bool { + guard let activeFilter = filters.first(where: { $0.enabled })?.name else { + return true + } + + switch activeFilter { + case .filterToAnsweredOrReacted: + let answered = message.answers?.contains(where: { $0.isCurrentUserAuthor }) ?? false + let reacted = message.reactions?.contains(where: { $0.user?.id == UserSessionFactory.shared.user?.id }) ?? false + return answered || reacted + case .filterToOwn: + let isOwn = message.isCurrentUserAuthor + let didReply = message.answers?.contains { $0.isCurrentUserAuthor } ?? false + return isOwn || didReply + case .filterToUnresolved: + return !(message.resolved ?? false) + default: + return true + } + } + + var queryItems: [URLQueryItem] { + let items: [URLQueryItem] = filters.compactMap { filter in + if filter.enabled { + return .init(name: filter.name, value: "true") + } else { + return nil + } + } + return items + } +} + +struct FilterOption: Codable, Hashable { + let name: String + let enabled: Bool + + var displayName: String { + switch name { + case .filterToAnsweredOrReacted: + return R.string.localizable.messageFilterReacted() + case .filterToUnresolved: + return R.string.localizable.messageFilterUnresolved() + case .filterToOwn: + return R.string.localizable.messageFilterOwn() + default: + return "" + } + } +} + +// MARK: String+Filter +fileprivate extension String { + static let filterToAnsweredOrReacted = "filterToAnsweredOrReacted" + static let filterToUnresolved = "filterToUnresolved" + static let filterToOwn = "filterToOwn" +} diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 00bd3e97..bfb47e67 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -84,11 +84,17 @@ // MARK: ConversationView "noMessages" = "No Messages"; "noMessagesDescription" = "Write the first message to kickstart this conversation."; +"noMatchingMessages" = "No messages match the selected filter."; "reply" = "reply"; "new" = "New"; "pinned" = "Pinned"; "resolved" = "Resolved"; "resolvesPost" = "Resolves Post"; +"filterMessages" = "Filter Messages"; +"details" = "Details"; +"messageFilterReacted" = "Reacted"; +"messageFilterUnresolved" = "Unresolved"; +"messageFilterOwn" = "Own"; // MARK: CreateChannelView "channelNameLabel" = "Name"; diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index 29da57f1..c2ef0032 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -36,7 +36,7 @@ protocol MessagesService { /** * Perform a get request for Messages of a specific conversation in a specific course to the server. */ - func getMessages(for courseId: Int, and conversationId: Int64, page: Int) async -> DataState<[Message]> + func getMessages(for courseId: Int, and conversationId: Int64, filter: MessageRequestFilter, page: Int) async -> DataState<[Message]> /** * Perform a post request for a new message for a specific conversation 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 0ea74bba..c0c13da2 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -136,6 +136,7 @@ struct MessagesServiceImpl: MessagesService { let courseId: Int let conversationId: Int64 let page: Int + let filter: MessageRequestFilter var method: HTTPMethod { return .get @@ -149,7 +150,7 @@ struct MessagesServiceImpl: MessagesService { .init(name: "pagingEnabled", value: "true"), .init(name: "page", value: String(describing: page)), .init(name: "size", value: String(describing: Self.size)) - ] + ] + filter.queryItems } var resourceName: String { @@ -157,8 +158,8 @@ struct MessagesServiceImpl: MessagesService { } } - func getMessages(for courseId: Int, and conversationId: Int64, page: Int) async -> DataState<[Message]> { - let result = await client.sendRequest(GetMessagesRequest(courseId: courseId, conversationId: conversationId, page: page)) + func getMessages(for courseId: Int, and conversationId: Int64, filter: MessageRequestFilter = .init(), page: Int) async -> DataState<[Message]> { + let result = await client.sendRequest(GetMessagesRequest(courseId: courseId, conversationId: conversationId, page: page, filter: filter)) switch result { case let .success((messages, _)): diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift index d3d709b3..f1fb4d34 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift @@ -109,7 +109,7 @@ extension MessagesServiceStub: MessagesService { .loading } - func getMessages(for courseId: Int, and conversationId: Int64, page: Int) async -> DataState<[Message]> { + func getMessages(for courseId: Int, and conversationId: Int64, filter: MessageRequestFilter, page: Int) async -> DataState<[Message]> { .done(response: messages) } diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index ff227a9c..cf938b3c 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -27,6 +27,16 @@ class ConversationViewModel: BaseViewModel { @Published var offlineMessages: [ConversationOfflineMessageModel] = [] + @Published var filter: MessageRequestFilter = .init() { + didSet { + isLoadingMessages = true + diff = 0 + page = 0 + Task { + await loadMessages(keepingOldMessages: false) + } + } + } @Published var isConversationInfoSheetPresented = false @Published var selectedMessageId: Int64? @Published var isLoadingMessages = true @@ -104,18 +114,19 @@ extension ConversationViewModel { await loadMessages() } - func loadMessages() async { + func loadMessages(keepingOldMessages: Bool = true) async { defer { isLoadingMessages = false } - let result = await messagesService.getMessages(for: course.id, and: conversation.id, page: page) + let result = await messagesService.getMessages(for: course.id, and: conversation.id, filter: filter, page: page) switch result { case .loading: break case let .done(response: response): // Keep existing members in new, i.e., update existing members in messages. - messages = Set(response.map(IdentifiableMessage.init)).union(messages) + messages = Set(response.map(IdentifiableMessage.init)) + .union(keepingOldMessages ? messages : []) if page > 0, response.count < MessagesServiceImpl.GetMessagesRequest.size { page -= 1 } @@ -127,7 +138,7 @@ extension ConversationViewModel { func loadMessage(messageId: Int64) async -> DataState { // TODO: add API to only load one single message - let result = await messagesService.getMessages(for: course.id, and: conversation.id, page: page) + let result = await messagesService.getMessages(for: course.id, and: conversation.id, filter: filter, page: page) return result.flatMap { messages in guard let message = messages.first(where: { $0.id == messageId }) else { return .failure(UserFacingError(title: R.string.localizable.messageCouldNotBeLoadedError())) @@ -138,7 +149,7 @@ extension ConversationViewModel { func loadAnswerMessage(answerMessageId: Int64) async -> DataState { // TODO: add API to only load one single answer message - let result = await messagesService.getMessages(for: course.id, and: conversation.id, page: page) + let result = await messagesService.getMessages(for: course.id, and: conversation.id, filter: filter, page: page) return result.flatMap { messages in guard let message = messages.first(where: { $0.answers?.contains(where: { $0.id == answerMessageId }) ?? false }), let answerMessage = message.answers?.first(where: { $0.id == answerMessageId }) else { @@ -396,6 +407,10 @@ private extension ConversationViewModel { } func handle(new message: Message) { + // Only insert message if it matches current filter + guard filter.messageMatchesSelectedFilter(message) else { + return + } shouldScrollToId = message.id.description let (inserted, _) = messages.insert(.message(message)) if inserted { @@ -419,6 +434,12 @@ private extension ConversationViewModel { return newAnswer } + // If message no longer matches filter, remove it + if !filter.messageMatchesSelectedFilter(newMessage) { + handle(delete: message) + return + } + messages.update(with: .message(newMessage)) } } diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index 1d91d740..47c6b96e 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -38,8 +38,11 @@ public struct ConversationView: View { ContentUnavailableView( R.string.localizable.noMessages(), systemImage: "bubble.right", - description: Text(R.string.localizable.noMessagesDescription())) - .loadingIndicator(isLoading: $viewModel.isLoadingMessages) + description: + viewModel.filter.selectedFilter == "all" ? + Text(R.string.localizable.noMessagesDescription()) : + Text(R.string.localizable.noMatchingMessages()) + ) } else { ScrollViewReader { value in ScrollView { @@ -91,6 +94,7 @@ public struct ConversationView: View { ) } } + .loadingIndicator(isLoading: $viewModel.isLoadingMessages) .toolbar { ToolbarItem(placement: .topBarLeading) { Button { @@ -122,10 +126,26 @@ public struct ConversationView: View { } } ToolbarItem(placement: .topBarTrailing) { - Button { - viewModel.isConversationInfoSheetPresented = true + Menu { + Button { + viewModel.isConversationInfoSheetPresented = true + } label: { + Label(R.string.localizable.details(), systemImage: "info") + } + Picker(selection: $viewModel.filter.selectedFilter) { + Text(R.string.localizable.allFilter()) + .tag("all") + ForEach(viewModel.filter.filters, id: \.self) { filter in + Text(filter.displayName) + .tag(filter.name) + } + } label: { + Label(R.string.localizable.filterMessages(), + systemImage: "line.3.horizontal.decrease") + } + .pickerStyle(.menu) } label: { - Image(systemName: "info.circle") + Image(systemName: "ellipsis.circle") } } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index dd844d92..f666cf6c 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -14,6 +14,7 @@ import SwiftUI struct MessageDetailView: View { + @EnvironmentObject var navController: NavigationController @ObservedObject var viewModel: ConversationViewModel @Binding private var message: DataState @@ -82,6 +83,17 @@ struct MessageDetailView: View { await reloadMessage() } } + .onChange(of: message) { + switch message { + case .loading: + // Message was deleted + if !navController.tabPath.isEmpty { + navController.tabPath.removeLast() + } + default: + break + } + } .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) .navigationBarTitleDisplayMode(.inline) } From ee598d4a4728e39b96f3abc79fe8f01b76a1544d Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:44:36 +0100 Subject: [PATCH 14/25] `Communication`: Improve image view (#212) * Use new image preview * Fix cell not being clickable in entire row * Update packages --- .../xcshareddata/swiftpm/Package.resolved | 14 +++++------ ArtemisKit/Package.swift | 2 +- .../Views/MessageDetailView/MessageCell.swift | 23 +++++++++---------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4cff713e..16f6ba03 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5efde1acc1a55b9d310c6b32ff1dbfb7a10bb2f1d3e9fc50636c2ee486b2a7ab", + "originHash" : "5cadd12433353b4144bcc99fd464b53c0aa36084b12784b90706859f84dad8c5", "pins" : [ { "identity" : "apollon-ios-module", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "346950fb2d5263c5f4fbecd5f4c4f6579b4d1669", - "version" : "15.0.0" + "revision" : "ffa278884a4c61262a0cdc227084e838a8649c89", + "version" : "15.1.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mac-cain13/R.swift.git", "state" : { - "revision" : "4ac2eb7e6157887c9f59dc5ccc5978d51546be6d", - "version" : "7.7.0" + "revision" : "a9abc6b0afe0fc4a5a71e1d7d2872143dff2d2f1", + "version" : "7.8.0" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tomlokhorst/XcodeEdit", "state" : { - "revision" : "017d23f71fa8d025989610db26d548c44cacefae", - "version" : "2.10.2" + "revision" : "1e761a55dd8d73b4e9cc227a297f438413953571", + "version" : "2.11.1" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 44f39dae..0b2557ac 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -20,7 +20,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/Kelvas09/EmojiPicker.git", from: "1.0.0"), .package(url: "https://github.com/ls1intum/apollon-ios-module", .upToNextMajor(from: "1.0.2")), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "15.0.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "15.1.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.7.0") ], targets: [ diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index d2aadc57..66869731 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -28,19 +28,18 @@ struct MessageCell: View { VStack(alignment: .leading, spacing: .s) { reactionMenuIfAvailable - HStack { - VStack(alignment: .leading, spacing: .s) { - pinnedIndicator - resolvesPostIndicator - headerIfVisible - ArtemisMarkdownView(string: content) - .opacity(isMessageOffline ? 0.5 : 1) - .environment(\.openURL, OpenURLAction(handler: handle)) - editedLabel - resolvedIndicator - } - Spacer() + VStack(alignment: .leading, spacing: .s) { + pinnedIndicator + resolvesPostIndicator + headerIfVisible + ArtemisMarkdownView(string: content.surroundingMarkdownImagesWithNewlines()) + .opacity(isMessageOffline ? 0.5 : 1) + .environment(\.openURL, OpenURLAction(handler: handle)) + .environment(\.imagePreviewsEnabled, viewModel.conversationPath == nil) + editedLabel + resolvedIndicator } + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) .onTapGesture(perform: onTapPresentMessage) .onLongPressGesture(perform: onLongPressPresentActionSheet) { changed in From 2b25bf51a0127e810bde3defa7eeb9b83c48f663 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Tue, 19 Nov 2024 20:35:59 +0100 Subject: [PATCH 15/25] `Communication`: Remove delivered notifications when entering/leaving channel (#226) --- .../ConversationViewModel.swift | 18 ++++++++++++++++++ .../ConversationView/ConversationView.swift | 3 +++ 2 files changed, 21 insertions(+) diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index cf938b3c..8700f6a7 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -10,9 +10,11 @@ import Foundation import Common import Combine import Extensions +import PushNotifications import SharedModels import SharedServices import UserStore +import UserNotifications class ConversationViewModel: BaseViewModel { @@ -89,6 +91,7 @@ class ConversationViewModel: BaseViewModel { Task { await loadMessages() + await removeAssociatedNotifications() } } @@ -307,6 +310,21 @@ extension ConversationViewModel { return .loading } } + + /// Removes all push notifications corresponding to this conversation + func removeAssociatedNotifications() async { + let notifications = await UNUserNotificationCenter.current().deliveredNotifications() + .filter { + guard let conversationId = PushNotificationResponseHandler.getConversationId(from: $0.request.content.userInfo) else { + return false + } + return conversationId == conversation.id + } + .map { + $0.request.identifier + } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: notifications) + } } // MARK: - Fileprivate diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index 47c6b96e..22f7a443 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -158,6 +158,9 @@ public struct ConversationView: View { SocketConnectionHandler.shared.cancelSubscriptions() } viewModel.saveContext() + Task { + await viewModel.removeAssociatedNotifications() + } } .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) .navigationBarTitleDisplayMode(.inline) From 0fe4aba5906d26b3d2ad2f895d6db5d9dd8df435 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Wed, 20 Nov 2024 20:16:01 +0100 Subject: [PATCH 16/25] `Notifications`: Fix notifications not navigating to target (#228) * Fix notifications not handling deep links when app is not running * Fix condition for clearing split view selection --- Artemis/ArtemisApp.swift | 4 +++- ArtemisKit/Sources/ArtemisKit/RootView.swift | 6 ++++-- ArtemisKit/Sources/CourseView/CourseView.swift | 6 ------ ArtemisKit/Sources/Navigation/NavigationController.swift | 1 + 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Artemis/ArtemisApp.swift b/Artemis/ArtemisApp.swift index b14e6ec0..2e8e6298 100644 --- a/Artemis/ArtemisApp.swift +++ b/Artemis/ArtemisApp.swift @@ -1,4 +1,5 @@ import ArtemisKit +import Navigation import SwiftUI @main @@ -8,10 +9,11 @@ struct ArtemisApp: App { private var delegate: AppDelegate @Environment(\.scenePhase) private var scenePhase + @StateObject private var navigationController = NavigationController() var body: some Scene { WindowGroup { - RootView() + RootView(navigationController: navigationController) .onChange(of: scenePhase) { _, newPhase in if newPhase == .background { delegate.applicationDidEnterBackground(UIApplication.shared) diff --git a/ArtemisKit/Sources/ArtemisKit/RootView.swift b/ArtemisKit/Sources/ArtemisKit/RootView.swift index 4d62f609..772f9cda 100644 --- a/ArtemisKit/Sources/ArtemisKit/RootView.swift +++ b/ArtemisKit/Sources/ArtemisKit/RootView.swift @@ -12,9 +12,11 @@ public struct RootView: View { @StateObject private var viewModel = RootViewModel() - @StateObject private var navigationController = NavigationController() + @ObservedObject private var navigationController: NavigationController - public init() {} + public init(navigationController: NavigationController) { + self.navigationController = navigationController + } public var body: some View { Group { diff --git a/ArtemisKit/Sources/CourseView/CourseView.swift b/ArtemisKit/Sources/CourseView/CourseView.swift index 7ac55e93..626872f1 100644 --- a/ArtemisKit/Sources/CourseView/CourseView.swift +++ b/ArtemisKit/Sources/CourseView/CourseView.swift @@ -67,12 +67,6 @@ public struct CourseView: View { .onChange(of: navigationController.courseTab) { searchText = "" } - .onDisappear { - if navigationController.outerPath.count < 2 { - // Reset selection if navigating back - navigationController.selectedPath = nil - } - } } } diff --git a/ArtemisKit/Sources/Navigation/NavigationController.swift b/ArtemisKit/Sources/Navigation/NavigationController.swift index 1af1a6fc..869cd8fe 100644 --- a/ArtemisKit/Sources/Navigation/NavigationController.swift +++ b/ArtemisKit/Sources/Navigation/NavigationController.swift @@ -46,6 +46,7 @@ public extension NavigationController { outerPath = NavigationPath() tabPath = NavigationPath() selectedCourse = nil + selectedPath = nil } func goToCourse(id: Int) { From eb06f8c925d7b4e24777d0f003ee8220296668e8 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Wed, 20 Nov 2024 20:30:44 +0100 Subject: [PATCH 17/25] `General`: Refactor search (#229) * Move exercise search into exercise list * Move lecture search into lecture list * Move message search into messages view --- .../Sources/CourseView/CourseView.swift | 35 ++----------------- .../ExerciseTab/ExerciseListView.swift | 3 +- .../LectureTab/LectureListView.swift | 3 +- .../MessagesTabViewModel.swift | 9 ----- .../MessagesAvailableView.swift | 10 +++--- .../MessagesTabView/MessagesPreferences.swift | 18 ---------- .../MessagesTabView/MessagesTabView.swift | 13 ++----- 7 files changed, 14 insertions(+), 77 deletions(-) delete mode 100644 ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesPreferences.swift diff --git a/ArtemisKit/Sources/CourseView/CourseView.swift b/ArtemisKit/Sources/CourseView/CourseView.swift index 626872f1..a1f1f873 100644 --- a/ArtemisKit/Sources/CourseView/CourseView.swift +++ b/ArtemisKit/Sources/CourseView/CourseView.swift @@ -10,17 +10,15 @@ public struct CourseView: View { @EnvironmentObject private var navigationController: NavigationController @StateObject private var viewModel: CourseViewModel - @StateObject private var messagesPreferences = MessagesPreferences() @State private var showNewMessageDialog = false - @State private var searchText = "" private let courseId: Int public var body: some View { TabView(selection: $navigationController.courseTab) { TabBarIpad { - ExerciseListView(viewModel: viewModel, searchText: $searchText) + ExerciseListView(viewModel: viewModel) } .tabItem { Label(R.string.localizable.exercisesTabLabel(), systemImage: "list.bullet.clipboard.fill") @@ -28,7 +26,7 @@ public struct CourseView: View { .tag(TabIdentifier.exercise) TabBarIpad { - LectureListView(viewModel: viewModel, searchText: $searchText) + LectureListView(viewModel: viewModel) } .tabItem { Label(R.string.localizable.lectureTabLabel(), systemImage: "character.book.closed.fill") @@ -37,8 +35,7 @@ public struct CourseView: View { if viewModel.isMessagesVisible { TabBarIpad { - MessagesTabView(course: viewModel.course, searchText: $searchText) - .environmentObject(messagesPreferences) + MessagesTabView(course: viewModel.course) } .tabItem { Label(R.string.localizable.messagesTabLabel(), systemImage: "bubble.right.fill") @@ -58,15 +55,6 @@ public struct CourseView: View { } .navigationTitle(viewModel.course.title ?? R.string.localizable.loading()) .navigationBarTitleDisplayMode(.inline) - .modifier( - // TODO: Move search into each tab, why is this even here? - SearchableIf( - condition: (navigationController.courseTab != .communication || messagesPreferences.isSearchable) && navigationController.courseTab != .faq, - text: $searchText) - ) - .onChange(of: navigationController.courseTab) { - searchText = "" - } } } @@ -75,20 +63,3 @@ extension CourseView { self.init(viewModel: CourseViewModel(course: course), courseId: course.id) } } - -/// `SearchableIf` modifies a view to be searchable if the condition is true. -/// -/// It appears, the `.searchable` modifier cannot be deeper in the hierarchy, i.e., further from the enclosing `NavigationStack`. -private struct SearchableIf: ViewModifier { - let condition: Bool - let text: Binding - - func body(content: Content) -> some View { - if condition { - content - .searchable(text: text) - } else { - content - } - } -} diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift index dfcab404..549dda93 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift @@ -12,7 +12,7 @@ struct ExerciseListView: View { @ObservedObject var viewModel: CourseViewModel @State private var columnVisibilty: NavigationSplitViewVisibility = .doubleColumn - @Binding var searchText: String + @State private var searchText = "" private var selectedExercise: Binding { navController.selectedPathBinding($navController.selectedPath) @@ -47,6 +47,7 @@ struct ExerciseListView: View { .listSectionSpacing(.compact) .scrollContentBackground(.hidden) .listRowSpacing(.m) + .searchable(text: $searchText) .refreshable { await viewModel.refreshCourse() } diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift index 743fbb09..42892019 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift @@ -17,7 +17,7 @@ struct LectureListView: View { @ObservedObject var viewModel: CourseViewModel @State private var columnVisibilty: NavigationSplitViewVisibility = .doubleColumn - @Binding var searchText: String + @State private var searchText = "" private var selectedLecture: Binding { navController.selectedPathBinding($navController.selectedPath) @@ -50,6 +50,7 @@ struct LectureListView: View { .listRowSpacing(.m) .listStyle(.insetGrouped) .scrollContentBackground(.hidden) + .searchable(text: $searchText) .refreshable { await viewModel.refreshCourse() } diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesTabViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesTabViewModel.swift index 87dcc2f2..cd6bacbf 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesTabViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesTabViewModel.swift @@ -19,15 +19,6 @@ class MessagesTabViewModel: BaseViewModel { @Published var codeOfConduct: DataState = .loading @Published var codeOfConductAgreement: DataState = .loading - var isSearchable: Bool { - if let codeOfConduct = course.courseInformationSharingMessagingCodeOfConduct, !codeOfConduct.isEmpty, - let agreement = codeOfConductAgreement.value, agreement { - return true - } else { - return false - } - } - init(course: Course) { self.course = course self.courseId = course.id diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index 25daa424..1f4ec102 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -20,7 +20,7 @@ public struct MessagesAvailableView: View { @State var columnVisibilty: NavigationSplitViewVisibility = .doubleColumn - @Binding private var searchText: String + @State private var searchText = "" @State private var isCodeOfConductPresented = false @@ -37,9 +37,8 @@ public struct MessagesAvailableView: View { navController.selectedPathBinding($navController.selectedPath) } - public init(course: Course, searchText: Binding) { + public init(course: Course) { self._viewModel = StateObject(wrappedValue: MessagesAvailableViewModel(course: course)) - self._searchText = searchText } public var body: some View { @@ -47,9 +46,7 @@ public struct MessagesAvailableView: View { List(selection: selectedConversation) { if !searchText.isEmpty { if searchResults.isEmpty { - Text(R.string.localizable.noResultForSearch()) - .padding(.l) - .listRowSeparator(.hidden) + ContentUnavailableView.search } ForEach(searchResults) { conversation in if let channel = conversation.baseConversation as? Channel { @@ -138,6 +135,7 @@ public struct MessagesAvailableView: View { .scrollContentBackground(.hidden) .listRowSpacing(0.01) .listSectionSpacing(.compact) + .searchable(text: $searchText) .refreshable { await viewModel.loadConversations() } diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesPreferences.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesPreferences.swift deleted file mode 100644 index f3da10e7..00000000 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesPreferences.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// MessagesPreferences.swift -// -// -// Created by Nityananda Zbil on 17.10.23. -// - -import SwiftUI - -/// `MessagesPreferences` is an environment object that signals preferences from `MessagesTabView` to its container view. -/// -/// Unfortunately, the `.preference(key:value:)` modifier did not update the value correctly at the container view. -public class MessagesPreferences: ObservableObject { - /// `isSearchable` signals if the `MessagesTabView` is searchable. - @Published public internal(set) var isSearchable = false - - public init() {} -} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift index bbd74e7b..3d1cff13 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift @@ -13,15 +13,10 @@ import SwiftUI public struct MessagesTabView: View { - @EnvironmentObject private var messagesPreferences: MessagesPreferences - @StateObject private var viewModel: MessagesTabViewModel - @Binding private var searchText: String - - public init(course: Course, searchText: Binding) { + public init(course: Course) { self._viewModel = StateObject(wrappedValue: MessagesTabViewModel(course: course)) - self._searchText = searchText } public var body: some View { @@ -29,7 +24,7 @@ public struct MessagesTabView: View { await viewModel.getCodeOfConductInformation() } content: { agreement in if agreement { - MessagesAvailableView(course: viewModel.course, searchText: _searchText) + MessagesAvailableView(course: viewModel.course) } else { ScrollView { CodeOfConductView(course: viewModel.course) @@ -46,14 +41,12 @@ public struct MessagesTabView: View { Spacer() } } + .navigationBarTitleDisplayMode(.inline) .contentMargins(.l, for: .scrollContent) } } .task { await viewModel.getCodeOfConductInformation() } - .onChange(of: viewModel.codeOfConductAgreement.value) { - messagesPreferences.isSearchable = viewModel.isSearchable - } } } From bd4910cf785d84e4faa4801f745fbc45e331aee5 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 22 Nov 2024 00:36:51 +0100 Subject: [PATCH 18/25] `Communication`: Make delete button red in message actions (#231) --- .../Messages/Views/MessageDetailView/MessageActions.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift index 378daaba..4e16e12e 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift @@ -146,6 +146,7 @@ struct MessageActions: View { Button(R.string.localizable.deleteMessage(), systemImage: "trash", role: .destructive) { showDeleteAlert = true } + .foregroundStyle(.red) .alert(R.string.localizable.confirmDeletionTitle(), isPresented: $showDeleteAlert) { Button(R.string.localizable.confirm(), role: .destructive) { viewModel.isLoading = true From b47f2c1d2dcd638cf5333532633c0f7f683d59ed Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 22 Nov 2024 23:37:59 +0100 Subject: [PATCH 19/25] `Lectures`: Show lecture videos in app (#232) --- .../LectureTab/LectureDetailView.swift | 30 ------- .../LectureTab/LectureUnitVideo.swift | 80 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 2 +- 3 files changed, 81 insertions(+), 31 deletions(-) create mode 100644 ArtemisKit/Sources/CourseView/LectureTab/LectureUnitVideo.swift diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift index aabbabfc..82ef3746 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift @@ -347,33 +347,3 @@ struct OnlineUnitSheetContent: View { } } } - -struct VideoUnitSheetContent: View { - - let videoUnit: VideoUnit - - var body: some View { - ScrollView { - VStack(alignment: .leading) { - if let description = videoUnit.description { - HStack { - VStack(alignment: .leading) { - Text(R.string.localizable.description()) - .font(.headline) - Text(description) - } - Spacer() - } - } - if let source = videoUnit.source, - let url = URL(string: source) { - Link(R.string.localizable.openVideo(), destination: url) - .buttonStyle(ArtemisButton()) - } else { - Text(R.string.localizable.videoCouldNotBeLoaded()) - .foregroundColor(.red) - } - }.padding(.l) - } - } -} diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureUnitVideo.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureUnitVideo.swift new file mode 100644 index 00000000..51828c83 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureUnitVideo.swift @@ -0,0 +1,80 @@ +// +// LectureUnitVideo.swift +// ArtemisKit +// +// Created by Anian Schleyer on 22.11.24. +// + +import AVKit +import DesignLibrary +import SharedModels +import SwiftUI + +struct VideoUnitSheetContent: View { + + let videoUnit: VideoUnit + var canPlayInline: Bool { + let supportedExtensions = ["m3u8", "mp4"] + guard let source = videoUnit.source, + let url = URL(string: source) else { + return false + } + return supportedExtensions.contains(url.pathExtension) + } + + var body: some View { + GeometryReader { proxy in + ScrollView { + if let description = videoUnit.description { + HStack { + VStack(alignment: .leading) { + Text(R.string.localizable.description()) + .font(.headline) + Text(description) + } + Spacer() + } + .padding(.horizontal) + } + + if let source = videoUnit.source, + let url = URL(string: source) { + if canPlayInline { + VideoPlayerView(url: url) + .frame(width: proxy.size.width, + height: min(proxy.size.height, proxy.size.width * 9 / 16)) + } + + Link(R.string.localizable.openVideo(), destination: url) + .buttonStyle(ArtemisButton()) + } else { + Text(R.string.localizable.videoCouldNotBeLoaded()) + .foregroundColor(.red) + } + } + } + } +} + +// Custom video player, because the default one doesn't allow full screen +private struct VideoPlayerView: UIViewControllerRepresentable { + + var url: URL + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.exitsFullScreenWhenPlaybackEnds = true + controller.videoGravity = .resizeAspect + + let player = AVPlayer(url: url) + player.preventsDisplaySleepDuringVideoPlayback = true + player.play() + + controller.player = player + + return controller + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: UIViewControllerRepresentableContext) { + } +} diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index 102bf853..6097f98e 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -89,7 +89,7 @@ "description" = "Description"; "openLink" = "Open Link"; "linkCouldNotBeLoaded" = "Link can not be loaded"; -"openVideo" = "Open Video"; +"openVideo" = "Open Video in Browser"; "videoCouldNotBeLoaded" = "Video url can not be loaded"; "notReleased" = "Not released"; "exerciseCouldNotBeLoaded" = "Exercise could not be loaded"; From 9bc0f09ffe8a1710335ef28792cf8edd5aa4a489 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 23 Nov 2024 01:07:29 +0100 Subject: [PATCH 20/25] `Communication`: Fix textfield not saving/restoring state correctly (#230) * Update existing answer draft instead of creating new one * Update existing message draft instead of creating new ones * Update existing server models * Fix swiftdata error messages * Add comment --- .../Messages/Models/Schema/SchemaV1.swift | 4 +- .../Repositories/MessagesRepository.swift | 45 +++++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift b/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift index c4a1b552..1d653d0d 100644 --- a/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift +++ b/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift @@ -102,7 +102,7 @@ enum SchemaV1: VersionedSchema { @Model final class Message { - var conversation: Conversation + var conversation: Conversation? @Attribute(.unique) var messageId: Int @@ -114,7 +114,7 @@ enum SchemaV1: VersionedSchema { var answerMessageDraft: String init( - conversation: Conversation, + conversation: Conversation?, messageId: Int, offlineAnswers: [MessageOfflineAnswer] = [], answerMessageDraft: String = "" diff --git a/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift index 88ea99e0..fe1741ea 100644 --- a/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift +++ b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift @@ -48,8 +48,10 @@ extension MessagesRepository { @discardableResult func insertServer(host: String) -> ServerModel { log.verbose("begin") - let server = ServerModel(host: host, lastAccessDate: .now) + let server = (try? fetchServer(host: host)) ?? ServerModel(host: host, lastAccessDate: .now) + server.lastAccessDate = .now container.mainContext.insert(server) + save() return server } @@ -69,8 +71,10 @@ extension MessagesRepository { log.verbose("begin") let server = try fetchServer(host: host) ?? insertServer(host: host) try touch(server: server) - let course = CourseModel(server: server, courseId: courseId) + let course = try fetchCourse(host: host, courseId: courseId) + ?? CourseModel(server: server, courseId: courseId) container.mainContext.insert(course) + save() return course } @@ -91,8 +95,15 @@ extension MessagesRepository { log.verbose("begin") let course = try fetchCourse(host: host, courseId: courseId) ?? insertCourse(host: host, courseId: courseId) try touch(server: course.server) - let conversation = ConversationModel(course: course, conversationId: conversationId, messageDraft: messageDraft) + let conversation = try fetchConversation(host: host, + courseId: courseId, + conversationId: conversationId) + ?? ConversationModel(course: course, + conversationId: conversationId, + messageDraft: "") + conversation.messageDraft = messageDraft container.mainContext.insert(conversation) + save() return conversation } @@ -155,8 +166,17 @@ extension MessagesRepository { let conversation = try fetchConversation(host: host, courseId: courseId, conversationId: conversationId) ?? insertConversation(host: host, courseId: courseId, conversationId: conversationId, messageDraft: "") try touch(server: conversation.course?.server) - let message = MessageModel(conversation: conversation, messageId: messageId, answerMessageDraft: answerMessageDraft) + let message = try fetchMessage(host: host, + courseId: courseId, + conversationId: conversationId, + messageId: messageId) + ?? MessageModel(conversation: conversation, + messageId: messageId, + answerMessageDraft: "") + message.answerMessageDraft = answerMessageDraft + container.mainContext.insert(conversation) container.mainContext.insert(message) + save() return message } @@ -164,10 +184,11 @@ extension MessagesRepository { log.verbose("begin") try purge(host: host) let predicate = #Predicate { message in - if let course = message.conversation.course { + if let conversation = message.conversation, + let course = conversation.course { course.server?.host == host && course.courseId == courseId - && message.conversation.conversationId == conversationId + && conversation.conversationId == conversationId && message.messageId == messageId } else { false @@ -186,7 +207,7 @@ extension MessagesRepository { log.verbose("begin") let message = try fetchMessage(host: host, courseId: courseId, conversationId: conversationId, messageId: messageId) ?? insertMessage(host: host, courseId: courseId, conversationId: conversationId, messageId: messageId, answerMessageDraft: "") - try touch(server: message.conversation.course?.server) + try touch(server: message.conversation?.course?.server) let answer = MessageOfflineAnswerModel(message: message, date: date, text: text) container.mainContext.insert(answer) return answer @@ -198,10 +219,11 @@ extension MessagesRepository { log.verbose("begin") try purge(host: host) let predicate = #Predicate { answer in - if let course = answer.message.conversation.course { + if let conversation = answer.message.conversation, + let course = conversation.course { course.server?.host == host && course.courseId == courseId - && answer.message.conversation.conversationId == conversationId + && conversation.conversationId == conversationId && answer.message.messageId == messageId } else { false @@ -234,5 +256,10 @@ extension MessagesRepository { container.mainContext.delete(server) } } + // Remove messages that don't belong to any conversation anymore + try container.mainContext.delete(model: MessageModel.self, where: #Predicate { message in + message.conversation == nil + }) + save() } } From c09724831e49ed628be07de42149f714cef94eb6 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 23 Nov 2024 20:23:40 +0100 Subject: [PATCH 21/25] `Communication`: Fix SwiftData crash (#233) --- .../Sources/Messages/Repositories/MessagesRepository.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift index fe1741ea..54693ca1 100644 --- a/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift +++ b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift @@ -94,6 +94,7 @@ extension MessagesRepository { 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) + container.mainContext.insert(course) try touch(server: course.server) let conversation = try fetchConversation(host: host, courseId: courseId, @@ -165,6 +166,7 @@ extension MessagesRepository { log.verbose("begin") let conversation = try fetchConversation(host: host, courseId: courseId, conversationId: conversationId) ?? insertConversation(host: host, courseId: courseId, conversationId: conversationId, messageDraft: "") + container.mainContext.insert(conversation) try touch(server: conversation.course?.server) let message = try fetchMessage(host: host, courseId: courseId, @@ -174,7 +176,6 @@ extension MessagesRepository { messageId: messageId, answerMessageDraft: "") message.answerMessageDraft = answerMessageDraft - container.mainContext.insert(conversation) container.mainContext.insert(message) save() return message From aecb0e0104247e68630f74a5b7b864975775ceb3 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 23 Nov 2024 20:38:09 +0100 Subject: [PATCH 22/25] `FAQ`: Allow FAQ questions to span multiple lines in detail view (#234) * Allow FAQ question to span multiple lines * Make background color less prominent --- ArtemisKit/Sources/Faq/Views/FaqDetailView.swift | 12 ++++++++---- ArtemisKit/Sources/Faq/Views/FaqListView.swift | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/ArtemisKit/Sources/Faq/Views/FaqDetailView.swift b/ArtemisKit/Sources/Faq/Views/FaqDetailView.swift index 55c0f132..fe596cfc 100644 --- a/ArtemisKit/Sources/Faq/Views/FaqDetailView.swift +++ b/ArtemisKit/Sources/Faq/Views/FaqDetailView.swift @@ -15,12 +15,16 @@ struct FaqDetailView: View { var body: some View { ScrollView { - ArtemisMarkdownView(string: faq.questionAnswer) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, .l) + VStack(alignment: .leading, spacing: .m) { + Text(faq.questionTitle) + .font(.title2.bold()) + ArtemisMarkdownView(string: faq.questionAnswer) + } + .frame(maxWidth: .infinity, alignment: .leading) } + .contentMargins(.l) .navigationTitle(faq.questionTitle) - .navigationBarTitleDisplayMode(.large) + .navigationBarTitleDisplayMode(.inline) .modifier(TransitionIfAvailable(id: faq.id, namespace: namespace)) } } diff --git a/ArtemisKit/Sources/Faq/Views/FaqListView.swift b/ArtemisKit/Sources/Faq/Views/FaqListView.swift index da838a3a..1c6361e3 100644 --- a/ArtemisKit/Sources/Faq/Views/FaqListView.swift +++ b/ArtemisKit/Sources/Faq/Views/FaqListView.swift @@ -131,7 +131,7 @@ private struct FaqListCell: View { ) .frame(maxHeight: .infinity, alignment: .bottom) } - .listRowBackground(Color.Artemis.exerciseCardBackgroundColor) + .listRowBackground(Color.Artemis.exerciseCardBackgroundColor.opacity(0.5)) .listRowInsets(EdgeInsets()) .id(FaqPath(faq: faq, namespace: namespace)) .matchedTransitionSource(id: faq.id, in: namespace) From 5288a8a34b4229883fd269c7dedbe5e8b2dcac5b Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 23 Nov 2024 21:24:52 +0100 Subject: [PATCH 23/25] `Chore`/`Communication`: Fix state change during view update console error (#235) --- .../SendMessageViewModel.swift | 22 ++++++++++++++----- .../SendMessageMentionContentView.swift | 2 +- .../SendMessageViews/SendMessageView.swift | 5 ++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index 4ff2c955..a3d8679a 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -45,7 +45,18 @@ final class SendMessageViewModel { // MARK: Text var text = "" - var selection: TextSelection? + private var _selection: TextSelection? + var selection: Binding { + Binding { + return self._selection + } set: { newValue in + // Ignore updates if text field is not focused + if !self.keyboardVisible && newValue != nil { + return + } + self._selection = newValue + } + } var isEditing: Bool { switch configuration { @@ -73,6 +84,7 @@ final class SendMessageViewModel { var wantsToAddMessageMentionContentType: MessageMentionContentType? var presentKeyboardOnAppear: Bool + var keyboardVisible = false // MARK: Life cycle @@ -214,7 +226,7 @@ extension SendMessageViewModel { let placeholderText = "\(before)\(placeholder)\(after)" var shouldSelectPlaceholder = false - if let selection { + if let selection = _selection { switch selection.indices { case .selection(let range): let newText: String @@ -226,7 +238,7 @@ extension SendMessageViewModel { } text.replaceSubrange(range, with: newText) if !shouldSelectPlaceholder, let endIndex = text.range(of: newText)?.upperBound { - self.selection = TextSelection(insertionPoint: endIndex) + self._selection = TextSelection(insertionPoint: endIndex) } default: break @@ -239,7 +251,7 @@ extension SendMessageViewModel { if shouldSelectPlaceholder { for range in text.ranges(of: placeholderText) { if let placeholderRange = text[range].range(of: placeholder) { - selection = TextSelection(range: range.clamped(to: placeholderRange)) + _selection = TextSelection(range: range.clamped(to: placeholderRange)) } } } @@ -277,7 +289,7 @@ extension SendMessageViewModel { } switch result { case .success: - selection = nil + _selection = nil text = "" default: return diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift index 9c457709..6a4766a8 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift @@ -15,7 +15,7 @@ struct SendMessageMentionContentView: View { var body: some View { NavigationStack { let delegate = SendMessageMentionContentDelegate { [weak viewModel] mention in - if let selection = viewModel?.selection { + if let selection = viewModel?.selection.wrappedValue { switch selection.indices { case .selection(let range): viewModel?.text.insert(contentsOf: mention, at: range.upperBound) diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index 6b38f7af..b5e56e63 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -40,6 +40,9 @@ struct SendMessageView: View { .background(.bar) } } + .onChange(of: isFocused, initial: true) { + viewModel.keyboardVisible = isFocused + } .onAppear { viewModel.performOnAppear() if viewModel.presentKeyboardOnAppear { @@ -96,7 +99,7 @@ private extension SendMessageView { TextField( R.string.localizable.messageAction(viewModel.conversation.baseConversation.conversationName), text: $viewModel.text, - selection: $viewModel.selection, + selection: viewModel.selection, axis: .vertical ) .textFieldStyle(.roundedBorder) From 0b7564305c633cf845c6a198dadfadd316d603ff Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 25 Nov 2024 20:50:00 +0100 Subject: [PATCH 24/25] `Notifications`: Improve notification delivery (#213) * Add notification extension * Add keychain group --- Artemis.entitlements | 4 + Artemis.xcodeproj/project.pbxproj | 182 +++++++++++++++++- .../ArtemisNotificationExtension.entitlements | 10 + ArtemisNotificationExtension/Info.plist | 13 ++ .../NotificationService.swift | 50 +++++ 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 ArtemisNotificationExtension/ArtemisNotificationExtension.entitlements create mode 100644 ArtemisNotificationExtension/Info.plist create mode 100644 ArtemisNotificationExtension/NotificationService.swift diff --git a/Artemis.entitlements b/Artemis.entitlements index e0e0ac64..f6da4e86 100644 --- a/Artemis.entitlements +++ b/Artemis.entitlements @@ -25,5 +25,9 @@ webcredentials:artemis-test6.artemis.cit.tum.de webcredentials:artemis-test9.artemis.cit.tum.de + keychain-access-groups + + $(AppIdentifierPrefix)de.tum.cit.ase.artemis + diff --git a/Artemis.xcodeproj/project.pbxproj b/Artemis.xcodeproj/project.pbxproj index 19425549..6568b025 100644 --- a/Artemis.xcodeproj/project.pbxproj +++ b/Artemis.xcodeproj/project.pbxproj @@ -10,12 +10,21 @@ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 2152FB042600AC8F00CF470E /* ArtemisApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* ArtemisApp.swift */; }; + 51B0EFC82CE6468700927F30 /* ArtemisNotificationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 51B0EFC12CE6468700927F30 /* ArtemisNotificationExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 51B0EFCF2CE646F300927F30 /* ArtemisKit in Frameworks */ = {isa = PBXBuildFile; productRef = 51B0EFCE2CE646F300927F30 /* ArtemisKit */; }; 51F1B2252C0CC26800F14D01 /* ArtemisUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F1B2242C0CC26800F14D01 /* ArtemisUITests.swift */; }; 51F1B22C2C0CC2D700F14D01 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F1B22B2C0CC2D700F14D01 /* SnapshotHelper.swift */; }; A166A2592B0381F000AB6119 /* ArtemisKit in Frameworks */ = {isa = PBXBuildFile; productRef = A166A2582B0381F000AB6119 /* ArtemisKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 51B0EFC62CE6468700927F30 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7555FF73242A565900829871 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 51B0EFC02CE6468700927F30; + remoteInfo = ArtemisNotificationExtesion; + }; 51F1B2262C0CC26800F14D01 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 7555FF73242A565900829871 /* Project object */; @@ -25,10 +34,25 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 51B0EFC92CE6468700927F30 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 51B0EFC82CE6468700927F30 /* ArtemisNotificationExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* ArtemisApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtemisApp.swift; sourceTree = ""; }; + 51B0EFC12CE6468700927F30 /* ArtemisNotificationExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ArtemisNotificationExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 51F1B2202C0CC26800F14D01 /* ArtemisUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ArtemisUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 51F1B2242C0CC26800F14D01 /* ArtemisUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtemisUITests.swift; sourceTree = ""; }; 51F1B22B2C0CC2D700F14D01 /* SnapshotHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; @@ -45,6 +69,27 @@ D52CEEAA29B8FA2D003C7B2E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 51B0EFCC2CE6468700927F30 /* Exceptions for "ArtemisNotificationExtension" folder in "ArtemisNotificationExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 51B0EFC02CE6468700927F30 /* ArtemisNotificationExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 51B0EFC22CE6468700927F30 /* ArtemisNotificationExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 51B0EFCC2CE6468700927F30 /* Exceptions for "ArtemisNotificationExtension" folder in "ArtemisNotificationExtension" target */, + ); + path = ArtemisNotificationExtension; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 48598CA0DF0DC47107BCF1DF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -54,6 +99,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 51B0EFBE2CE6468700927F30 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 51B0EFCF2CE646F300927F30 /* ArtemisKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 51F1B21D2C0CC26800F14D01 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -95,6 +148,7 @@ A1C7E0A92B03754200804542 /* ArtemisKit */, 7555FF7D242A565900829871 /* Artemis */, 51F1B2212C0CC26800F14D01 /* ArtemisUITests */, + 51B0EFC22CE6468700927F30 /* ArtemisNotificationExtension */, 7555FF7C242A565900829871 /* Products */, 22B6A91C292D785600F08C7E /* Frameworks */, ); @@ -105,6 +159,7 @@ children = ( 7555FF7B242A565900829871 /* Artemis.app */, 51F1B2202C0CC26800F14D01 /* ArtemisUITests.xctest */, + 51B0EFC12CE6468700927F30 /* ArtemisNotificationExtension.appex */, ); name = Products; sourceTree = ""; @@ -138,6 +193,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 51B0EFC02CE6468700927F30 /* ArtemisNotificationExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 51B0EFCD2CE6468700927F30 /* Build configuration list for PBXNativeTarget "ArtemisNotificationExtension" */; + buildPhases = ( + 51B0EFBD2CE6468700927F30 /* Sources */, + 51B0EFBE2CE6468700927F30 /* Frameworks */, + 51B0EFBF2CE6468700927F30 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 51B0EFC22CE6468700927F30 /* ArtemisNotificationExtension */, + ); + name = ArtemisNotificationExtension; + packageProductDependencies = ( + 51B0EFCE2CE646F300927F30 /* ArtemisKit */, + ); + productName = ArtemisNotificationExtesion; + productReference = 51B0EFC12CE6468700927F30 /* ArtemisNotificationExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 51F1B21F2C0CC26800F14D01 /* ArtemisUITests */ = { isa = PBXNativeTarget; buildConfigurationList = 51F1B22A2C0CC26800F14D01 /* Build configuration list for PBXNativeTarget "ArtemisUITests" */; @@ -166,10 +244,12 @@ 7555FF77242A565900829871 /* Sources */, 7555FF79242A565900829871 /* Resources */, 48598CA0DF0DC47107BCF1DF /* Frameworks */, + 51B0EFC92CE6468700927F30 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 51B0EFC72CE6468700927F30 /* PBXTargetDependency */, ); name = Artemis; packageProductDependencies = ( @@ -186,10 +266,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1520; + LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1500; ORGANIZATIONNAME = TUM; TargetAttributes = { + 51B0EFC02CE6468700927F30 = { + CreatedOnToolsVersion = 16.0; + }; 51F1B21F2C0CC26800F14D01 = { CreatedOnToolsVersion = 15.2; TestTargetID = 7555FF7A242A565900829871; @@ -217,11 +300,19 @@ targets = ( 7555FF7A242A565900829871 /* Artemis */, 51F1B21F2C0CC26800F14D01 /* ArtemisUITests */, + 51B0EFC02CE6468700927F30 /* ArtemisNotificationExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 51B0EFBF2CE6468700927F30 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 51F1B21E2C0CC26800F14D01 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -261,6 +352,13 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 51B0EFBD2CE6468700927F30 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 51F1B21C2C0CC26800F14D01 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -281,6 +379,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 51B0EFC72CE6468700927F30 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 51B0EFC02CE6468700927F30 /* ArtemisNotificationExtension */; + targetProxy = 51B0EFC62CE6468700927F30 /* PBXContainerItemProxy */; + }; 51F1B2272C0CC26800F14D01 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 7555FF7A242A565900829871 /* Artemis */; @@ -289,6 +392,70 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 51B0EFCA2CE6468700927F30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = ArtemisNotificationExtension/ArtemisNotificationExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = T7PP2KY2B6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ArtemisNotificationExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Artemis; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 TUM. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.tum.cit.ase.artemis.ArtemisNotificationExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 51B0EFCB2CE6468700927F30 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = ArtemisNotificationExtension/ArtemisNotificationExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7PP2KY2B6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ArtemisNotificationExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Artemis; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 TUM. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.tum.cit.ase.artemis.ArtemisNotificationExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 51F1B2282C0CC26800F14D01 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -515,6 +682,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 51B0EFCD2CE6468700927F30 /* Build configuration list for PBXNativeTarget "ArtemisNotificationExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 51B0EFCA2CE6468700927F30 /* Debug */, + 51B0EFCB2CE6468700927F30 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 51F1B22A2C0CC26800F14D01 /* Build configuration list for PBXNativeTarget "ArtemisUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -545,6 +721,10 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 51B0EFCE2CE646F300927F30 /* ArtemisKit */ = { + isa = XCSwiftPackageProductDependency; + productName = ArtemisKit; + }; A166A2582B0381F000AB6119 /* ArtemisKit */ = { isa = XCSwiftPackageProductDependency; productName = ArtemisKit; diff --git a/ArtemisNotificationExtension/ArtemisNotificationExtension.entitlements b/ArtemisNotificationExtension/ArtemisNotificationExtension.entitlements new file mode 100644 index 00000000..a12d5ffa --- /dev/null +++ b/ArtemisNotificationExtension/ArtemisNotificationExtension.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)de.tum.cit.ase.artemis + + + diff --git a/ArtemisNotificationExtension/Info.plist b/ArtemisNotificationExtension/Info.plist new file mode 100644 index 00000000..57421ebf --- /dev/null +++ b/ArtemisNotificationExtension/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/ArtemisNotificationExtension/NotificationService.swift b/ArtemisNotificationExtension/NotificationService.swift new file mode 100644 index 00000000..3ad1f687 --- /dev/null +++ b/ArtemisNotificationExtension/NotificationService.swift @@ -0,0 +1,50 @@ +// +// NotificationService.swift +// ArtemisNotificationExtension +// +// Created by Anian Schleyer on 14.11.24. +// Copyright © 2024 TUM. All rights reserved. +// + +import PushNotifications +import UserNotifications + +class NotificationService: UNNotificationServiceExtension { + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + guard var bestAttemptContent else { + contentHandler(request.content) + return + } + + // Decrypt notification and deliver it + let payload = bestAttemptContent.userInfo + guard let payloadString = payload["payload"] as? String, + let initVector = payload["iv"] as? String else { + return + } + + Task { + bestAttemptContent = await PushNotificationHandler + .extractNotification(from: payloadString, iv: initVector) ?? bestAttemptContent + + contentHandler(bestAttemptContent) + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler, let bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} From d645ad20fd970e25099b8e68a9451274e09280e3 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 25 Nov 2024 21:14:07 +0100 Subject: [PATCH 25/25] `Chore`: Update comments and remove debug prints (#237) * Update comment * Remove debug print statements --- .../Sources/Messages/Networking/SocketConnectionHandler.swift | 3 --- .../Services/MessagesService/MessagesServiceImpl.swift | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift b/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift index 49b3d750..ad5a281c 100644 --- a/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift +++ b/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift @@ -50,8 +50,6 @@ class SocketConnectionHandler { guard let messageWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: MessageWebsocketDTO.self, message: message) else { continue } - print("Stomp channel") - messagePublisher.send(messageWebsocketDTO) } } @@ -75,7 +73,6 @@ class SocketConnectionHandler { guard let messageWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: MessageWebsocketDTO.self, message: message) else { continue } - print("Stomp convo") messagePublisher.send(messageWebsocketDTO) } } diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift index c0c13da2..b953189d 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -933,7 +933,7 @@ private extension ConversationType { // MARK: Reload Notification extension Foundation.Notification.Name { - // Sending a notification of this type causes the Notification List to be reloaded, - // when favorites are changed from elsewhere. + // Sending a notification of this type causes the Conversation + // to add the newly sent message in case the web socket fails static let newMessageSent = Foundation.Notification.Name("NewMessageSent") }