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/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ed6a521..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" : "abe60a35389b3a48746220c8c769e40edfe597e7cfc1f2c27bfcbd88959c19bb", + "originHash" : "5cadd12433353b4144bcc99fd464b53c0aa36084b12784b90706859f84dad8c5", "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" : "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/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/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..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: "14.7.1")), + .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: [ @@ -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/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..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,21 +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 = "" - } - .onDisappear { - if navigationController.outerPath.count < 2 { - // Reset selection if navigating back - navigationController.selectedPath = nil - } - } } } @@ -81,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/LectureDetailView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift index 5146bd02..82ef3746 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) @@ -345,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/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/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"; 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) 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/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/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/Networking/SocketConnectionHandler.swift b/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift new file mode 100644 index 00000000..ad5a281c --- /dev/null +++ b/ArtemisKit/Sources/Messages/Networking/SocketConnectionHandler.swift @@ -0,0 +1,102 @@ +// +// 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 + } + 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 + } + 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/Repositories/MessagesRepository.swift b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift index 88ea99e0..54693ca1 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 } @@ -90,9 +94,17 @@ 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 = 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 } @@ -154,9 +166,18 @@ 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 = 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(message) + save() return message } @@ -164,10 +185,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 +208,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 +220,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 +257,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() } } diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index c2b53483..bfb47e67 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"; @@ -83,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"; @@ -100,6 +107,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"; @@ -155,6 +164,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..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. @@ -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..b953189d 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, _)): @@ -169,7 +170,7 @@ struct MessagesServiceImpl: MessagesService { } struct SendMessageRequest: APIRequest { - typealias Response = RawResponse + typealias Response = Message let courseId: Int let visibleForStudents: Bool @@ -192,7 +193,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) @@ -229,6 +233,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 @@ -900,3 +929,11 @@ private extension ConversationType { } } } + +// MARK: Reload Notification + +extension Foundation.Notification.Name { + // 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") +} diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift index 15219ef6..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) } @@ -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/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index 5f637994..8700f6a7 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -8,12 +8,14 @@ import APIClient import Foundation import Common +import Combine import Extensions +import PushNotifications import SharedModels import SharedServices import UserStore +import UserNotifications -@MainActor class ConversationViewModel: BaseViewModel { let course: Course @@ -27,8 +29,19 @@ 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 var isAllowedToPost: Bool { guard let channel = conversation.baseConversation as? Channel else { @@ -46,11 +59,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( @@ -58,7 +70,6 @@ class ConversationViewModel: BaseViewModel { conversation: Conversation, messagesRepository: MessagesRepository? = nil, messagesService: MessagesService = MessagesServiceFactory.shared, - stompClient: ArtemisStompClient = .shared, userSession: UserSession = UserSessionFactory.shared ) { self.course = course @@ -66,7 +77,6 @@ class ConversationViewModel: BaseViewModel { self.messagesRepository = messagesRepository ?? .shared self.messagesService = messagesService - self.stompClient = stompClient self.userSession = userSession super.init() @@ -78,6 +88,11 @@ class ConversationViewModel: BaseViewModel { selector: #selector(updateFavorites(notification:)), name: .favoriteConversationChanged, object: nil) + + Task { + await loadMessages() + await removeAssociatedNotifications() + } } deinit { @@ -102,14 +117,19 @@ extension ConversationViewModel { await loadMessages() } - func loadMessages() async { - let result = await messagesService.getMessages(for: course.id, and: conversation.id, page: page) + func loadMessages(keepingOldMessages: Bool = true) async { + defer { + isLoadingMessages = false + } + + 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 } @@ -121,7 +141,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())) @@ -132,7 +152,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 { @@ -290,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 @@ -321,43 +356,28 @@ private extension ConversationViewModel { // MARK: Initializer func subscribeToConversationTopic() { - let topic: String - if conversation.baseConversation.type == .channel, - let channel = conversation.baseConversation as? Channel, - channel.isCourseWide == true { - topic = WebSocketTopic.makeChannelNotifications(courseId: course.id) - } else if let id = userSession.user?.id { - topic = WebSocketTopic.makeConversationNotifications(userId: id) - } else { - return - } - 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 - } - + 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 { + socketConnection.subscribeToChannelNotifications(courseId: course.id) + } else if let id = userSession.user?.id { + socketConnection.subscribeToConversationNotifications(userId: id) } + + NotificationCenter.default.addObserver(self, + selector: #selector(onOwnMessageSent(notification:)), + name: .newMessageSent, + object: nil) } func fetchOfflineMessages() { @@ -381,19 +401,34 @@ 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 + } + } + } + + @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) { + // 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 { @@ -417,6 +452,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/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/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/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/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..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 @@ -205,12 +217,16 @@ 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)" var shouldSelectPlaceholder = false - if let selection { + if let selection = _selection { switch selection.indices { case .selection(let range): let newText: String @@ -222,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 @@ -235,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)) } } } @@ -273,7 +289,7 @@ extension SendMessageViewModel { } switch result { case .success: - selection = nil + _selection = nil text = "" default: return diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index bd378345..22f7a443 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 @@ -39,7 +38,11 @@ public struct ConversationView: View { ContentUnavailableView( R.string.localizable.noMessages(), systemImage: "bubble.right", - description: Text(R.string.localizable.noMessagesDescription())) + 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 { @@ -98,8 +102,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) { @@ -124,28 +126,41 @@ 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") } } } .sheet(isPresented: $viewModel.isConversationInfoSheetPresented) { ConversationInfoSheetView(course: viewModel.course, conversation: $viewModel.conversation) } - .task { - viewModel.shouldScrollToId = "bottom" - 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() + Task { + await viewModel.removeAssociatedNotifications() + } } .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) .navigationBarTitleDisplayMode(.inline) 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/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) } } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift index 591cc4dc..4e16e12e 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,10 +141,12 @@ struct MessageActions: View { editMessage .font(nil) } - + } + if canDelete { 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 diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 1ecbedbb..66869731 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 @@ -27,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 @@ -364,6 +364,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/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 6890b4ec..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 @@ -60,11 +61,18 @@ struct MessageDetailView: View { Text(R.string.localizable.thread()) .fontWeight(.semibold) HStack(spacing: .s) { - viewModel.conversation.baseConversation.icon? - .resizable() - .scaledToFit() - .frame(height: .m * 1.5) - Text(viewModel.conversation.baseConversation.conversationName) + 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) + // Workaround: Trailing spaces, otherwise SwiftUI shortens this prematurely + Text(viewModel.conversation.baseConversation.conversationName + " ") + .frame(maxWidth: 220) } .font(.footnote) }.padding(.leading, .m) @@ -75,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) } 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/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index aa053c04..1f4ec102 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 @@ -19,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 @@ -36,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 { @@ -46,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 { @@ -137,6 +135,7 @@ public struct MessagesAvailableView: View { .scrollContentBackground(.hidden) .listRowSpacing(0.01) .listSectionSpacing(.compact) + .searchable(text: $searchText) .refreshable { await viewModel.loadConversations() } @@ -189,6 +188,9 @@ public struct MessagesAvailableView: View { } } .modifier(NavigationDestinationMessagesModifier()) + .navigationDestination(for: FaqPath.self) { path in + FaqPathView(path: path) + } } } .toolbar(.hidden, for: .navigationBar) 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 - } } } 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/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 9d4732a7..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) @@ -135,7 +138,6 @@ private extension SendMessageView { } } label: { Label(R.string.localizable.mention(), systemImage: "plus.circle.fill") - .labelStyle(.iconOnly) } Menu { Button { @@ -155,13 +157,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 +176,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() 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) { 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) } 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 } 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) + } + } + +} 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)