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)