diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b4d117c6..2a482e51 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "branch" : "release-candidate/10.0.0", - "revision" : "ac82b556d645bf22b37713e2ff00f1e6c88cbab6" + "revision" : "969303b0a2ab90a4a7150accc3d18764b9bde37f", + "version" : "10.0.0" } }, { @@ -194,8 +194,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams.git", "state" : { - "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", - "version" : "5.0.6" + "revision" : "8a835d918245ca22f36663dd3862138805d7f707", + "version" : "5.1.0" } } ], diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 0c1b3a9e..4b08d4c1 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .package(url: "https://github.com/Kelvas09/EmojiPicker.git", from: "1.0.0"), .package(url: "https://github.com/ls1intum/apollon-ios-module", .upToNextMajor(from: "1.0.2")), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", branch: "release-candidate/10.0.0"), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "10.0.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ diff --git a/ArtemisKit/Sources/Dashboard/CourseGridCell.swift b/ArtemisKit/Sources/Dashboard/CourseGridCell.swift index 1d669678..5369a2ff 100644 --- a/ArtemisKit/Sources/Dashboard/CourseGridCell.swift +++ b/ArtemisKit/Sources/Dashboard/CourseGridCell.swift @@ -5,7 +5,6 @@ // Created by Nityananda Zbil on 19.03.24. // -import Charts import DesignLibrary import Navigation import SharedModels diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index f3b0d6ac..70e0bd4c 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -99,10 +99,9 @@ extension ConversationViewModel { 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) - if response.count < MessagesServiceImpl.GetMessagesRequest.size { + if page > 0, response.count < MessagesServiceImpl.GetMessagesRequest.size { page -= 1 } - log.error(page) diff = 0 case let .failure(error: error): presentError(userFacingError: error) diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift new file mode 100644 index 00000000..548c70e7 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift @@ -0,0 +1,44 @@ +// +// MessageCellModel+MentionScheme.swift +// +// +// Created by Nityananda Zbil on 25.03.24. +// + +import Foundation + +enum MentionScheme { + case channel(Int64) + case exercise(Int) + case lecture(Int) + case member(String) + + init?(_ url: URL) { + guard url.scheme == "mention" else { + return nil + } + switch url.host() { + case "channel": + if let id = Int64(url.lastPathComponent) { + self = .channel(id) + return + } + case "exercise": + if let id = Int(url.lastPathComponent) { + self = .exercise(id) + return + } + case "lecture": + if let id = Int(url.lastPathComponent) { + self = .lecture(id) + return + } + case "member": + self = .member(url.lastPathComponent) + return + default: + return nil + } + return nil + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift new file mode 100644 index 00000000..a8178379 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift @@ -0,0 +1,77 @@ +// +// MessageCellModel.swift +// +// +// Created by Nityananda Zbil on 25.03.24. +// + +import Foundation +import Navigation +import SharedModels +import UserStore + +@MainActor +@Observable +final class MessageCellModel { + let course: Course + + let conversationPath: ConversationPath? + let isHeaderVisible: Bool + let retryButtonAction: (() -> Void)? + + var isActionSheetPresented = false + var isDetectingLongPress = false + + private let messagesService: MessagesService + private let userSession: UserSession + + init( + course: Course, + conversationPath: ConversationPath?, + isHeaderVisible: Bool, + retryButtonAction: (() -> Void)?, + messagesService: MessagesService = MessagesServiceFactory.shared, + userSession: UserSession = .shared + ) { + self.course = course + self.conversationPath = conversationPath + self.isHeaderVisible = isHeaderVisible + self.retryButtonAction = retryButtonAction + self.messagesService = messagesService + self.userSession = userSession + } +} + +extension MessageCellModel { + // MARK: View + + func isChipVisible(creationDate: Date, authorId: Int64?) -> Bool { + guard let lastReadDate = conversationPath?.conversation?.baseConversation.lastReadDate else { + return false + } + + return lastReadDate < creationDate && userSession.user?.id != authorId + } + + // MARK: Navigation + + func getOneToOneChatOrCreate(login: String) async -> Conversation? { + async let conversations = messagesService.getConversations(for: course.id) + async let chat = messagesService.createOneToOneChat(for: course.id, usernames: [login]) + + if let conversations = await conversations.value, + let conversation = conversations.first(where: { conversation in + guard case let .oneToOneChat(conversation) = conversation, + let members = conversation.members else { + return false + } + return members.map(\.login).contains(login) + }) { + return conversation + } else if let chat = await chat.value { + return Conversation.oneToOneChat(conversation: chat) + } + + return nil + } +} diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationDaySection.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationDaySection.swift index 41ad2d10..0ca1ba27 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationDaySection.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationDaySection.swift @@ -63,7 +63,7 @@ private struct MessageCellWrapper: View { var body: some View { MessageCell( - viewModel: viewModel, + conversationViewModel: viewModel, message: messageBinding, conversationPath: conversationPath, isHeaderVisible: isHeaderVisible) diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationOfflineSection.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationOfflineSection.swift index 36f90bcc..7bedadb6 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationOfflineSection.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationOfflineSection.swift @@ -16,7 +16,7 @@ struct ConversationOfflineSection: View { var body: some View { Group { MessageCell( - viewModel: conversationViewModel, + conversationViewModel: conversationViewModel, message: Binding.constant(DataState.done(response: OfflineMessageOrAnswer(viewModel.message))), conversationPath: nil, isHeaderVisible: viewModel.taskDidFail, @@ -30,7 +30,7 @@ struct ConversationOfflineSection: View { } ForEach(viewModel.messageQueue) { message in MessageCell( - viewModel: conversationViewModel, + conversationViewModel: conversationViewModel, message: Binding.constant(DataState.done(response: OfflineMessageOrAnswer(message))), conversationPath: nil, isHeaderVisible: false diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 80d9d4e1..30d7551b 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -11,32 +11,23 @@ import DesignLibrary import Navigation import SharedModels import SwiftUI -import UserStore struct MessageCell: View { @Environment(\.isMessageOffline) var isMessageOffline: Bool @EnvironmentObject var navigationController: NavigationController - @ObservedObject var viewModel: ConversationViewModel + @ObservedObject var conversationViewModel: ConversationViewModel @Binding var message: DataState - @State private var isActionSheetPresented = false - @State private var isDetectingLongPress = false - - var user: () -> User? = { UserSession.shared.user } - - let conversationPath: ConversationPath? - let isHeaderVisible: Bool - - var retryButtonAction: (() -> Void)? + @State var viewModel: MessageCellModel var body: some View { HStack(alignment: .top, spacing: .m) { Image(systemName: "person") .resizable() .scaledToFit() - .frame(width: 40, height: isHeaderVisible ? 40 : 0) + .frame(width: 40, height: viewModel.isHeaderVisible ? 40 : 0) .padding(.top, .s) VStack(alignment: .leading, spacing: .xs) { HStack { @@ -44,6 +35,7 @@ struct MessageCell: View { headerIfVisible ArtemisMarkdownView(string: content) .opacity(isMessageOffline ? 0.5 : 1) + .environment(\.openURL, OpenURLAction(handler: handle)) } Spacer() } @@ -54,23 +46,47 @@ struct MessageCell: View { .contentShape(.rect) .onTapGesture(perform: onTapPresentMessage) .onLongPressGesture(perform: onLongPressPresentActionSheet) { changed in - isDetectingLongPress = changed + viewModel.isDetectingLongPress = changed } - ReactionsView(viewModel: viewModel, message: $message) + ReactionsView(viewModel: conversationViewModel, message: $message) retryButtonIfAvailable replyButtonIfAvailable } .id(message.value?.id.description) } .padding(.horizontal, .l) - .sheet(isPresented: $isActionSheetPresented) { - MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: conversationPath) - .presentationDetents([.height(350), .large]) + .sheet(isPresented: $viewModel.isActionSheetPresented) { + MessageActionSheet( + viewModel: conversationViewModel, + message: $message, + conversationPath: viewModel.conversationPath + ) + .presentationDetents([.height(350), .large]) } } } +extension MessageCell { + init( + conversationViewModel: ConversationViewModel, + message: Binding>, + conversationPath: ConversationPath?, + isHeaderVisible: Bool, + retryButtonAction: (() -> Void)? = nil + ) { + self.init( + conversationViewModel: conversationViewModel, + message: message, + viewModel: MessageCellModel( + course: conversationViewModel.course, + conversationPath: conversationPath, + isHeaderVisible: isHeaderVisible, + retryButtonAction: retryButtonAction) + ) + } +} + private extension MessageCell { var author: String { message.value?.author?.name ?? "" @@ -85,11 +101,11 @@ private extension MessageCell { } var backgroundOnPress: Color { - (isDetectingLongPress || isActionSheetPresented) ? Color.Artemis.messsageCellPressed : Color.clear + (viewModel.isDetectingLongPress || viewModel.isActionSheetPresented) ? Color.Artemis.messsageCellPressed : Color.clear } @ViewBuilder var headerIfVisible: some View { - if isHeaderVisible { + if viewModel.isHeaderVisible { HStack(alignment: .firstTextBaseline, spacing: .m) { Text(isMessageOffline ? "Redacted" : author) .bold() @@ -110,22 +126,16 @@ private extension MessageCell { padding: .s ) .font(.footnote) - .opacity(isChipVisible(creationDate: creationDate) ? 1 : 0) + .opacity( + viewModel.isChipVisible(creationDate: creationDate, authorId: message.value?.author?.id) ? 1 : 0 + ) } } } } - func isChipVisible(creationDate: Date) -> Bool { - guard let lastReadDate = conversationPath?.conversation?.baseConversation.lastReadDate else { - return false - } - - return lastReadDate < creationDate && user()?.id != message.value?.author?.id - } - @ViewBuilder var retryButtonIfAvailable: some View { - if let retryButtonAction { + if let retryButtonAction = viewModel.retryButtonAction { Button(action: retryButtonAction) { Label { Text("Failed to send") @@ -140,16 +150,16 @@ private extension MessageCell { @ViewBuilder var replyButtonIfAvailable: some View { if let message = message.value as? Message, let answerCount = message.answers?.count, answerCount > 0, - let conversationPath { + let conversationPath = viewModel.conversationPath { Button { if let messagePath = MessagePath( message: self.$message, conversationPath: conversationPath, - conversationViewModel: viewModel + conversationViewModel: conversationViewModel ) { navigationController.path.append(messagePath) } else { - viewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) + conversationViewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } } label: { Label { @@ -165,24 +175,46 @@ private extension MessageCell { func onTapPresentMessage() { // Tap is disabled, if conversation path is nil, e.g., in the message detail view. - if let conversationPath, let messagePath = MessagePath( + if let conversationPath = viewModel.conversationPath, let messagePath = MessagePath( message: $message, conversationPath: conversationPath, - conversationViewModel: viewModel + conversationViewModel: conversationViewModel ) { navigationController.path.append(messagePath) } } func onLongPressPresentActionSheet() { - if let channel = viewModel.conversation.baseConversation as? Channel, channel.isArchived ?? false { + if let channel = conversationViewModel.conversation.baseConversation as? Channel, channel.isArchived ?? false { return } let feedback = UIImpactFeedbackGenerator(style: .heavy) feedback.impactOccurred() - isActionSheetPresented = true - isDetectingLongPress = false + viewModel.isActionSheetPresented = true + viewModel.isDetectingLongPress = false + } + + func handle(url: URL) -> OpenURLAction.Result { + if let mention = MentionScheme(url) { + let coursePath = CoursePath(course: conversationViewModel.course) + switch mention { + case let .channel(id): + navigationController.path.append(ConversationPath(id: id, coursePath: coursePath)) + case let .exercise(id): + navigationController.path.append(ExercisePath(id: id, coursePath: coursePath)) + case let .lecture(id): + navigationController.path.append(LecturePath(id: id, coursePath: coursePath)) + case let .member(login): + Task { + if let conversation = await viewModel.getOneToOneChatOrCreate(login: login) { + navigationController.path.append(ConversationPath(conversation: conversation, coursePath: coursePath)) + } + } + } + return .handled + } + return .systemAction } } @@ -205,7 +237,7 @@ extension EnvironmentValues { #Preview { MessageCell( - viewModel: ConversationViewModel( + conversationViewModel: ConversationViewModel( course: MessagesServiceStub.course, conversation: MessagesServiceStub.conversation), message: Binding.constant(DataState.done(response: MessagesServiceStub.message)), diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 31a6d622..e0efbdc8 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -67,7 +67,7 @@ struct MessageDetailView: View { private extension MessageDetailView { func top(message: BaseMessage) -> some View { MessageCell( - viewModel: viewModel, + conversationViewModel: viewModel, message: Binding.constant(DataState.done(response: message)), conversationPath: nil, isHeaderVisible: true @@ -165,7 +165,7 @@ private struct MessageCellWrapper: View { var body: some View { MessageCell( - viewModel: viewModel, + conversationViewModel: viewModel, message: answerMessageBinding, conversationPath: nil, isHeaderVisible: isHeaderVisible) diff --git a/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift index e64184fb..fb24e254 100644 --- a/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift +++ b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift @@ -54,7 +54,7 @@ public class DeeplinkHandler { } private func buildHandler(from url: URL) -> Deeplink? { - // Attention: the order of the array matters + // Attention: the order of the array matters; ordered from deepest to shallowest. let builders: [(URL) -> Deeplink?] = [ ExerciseHandler.build, LectureHandler.build, diff --git a/ArtemisKit/Sources/Navigation/Paths.swift b/ArtemisKit/Sources/Navigation/Paths.swift index ae5cc4e6..0d7d97fc 100644 --- a/ArtemisKit/Sources/Navigation/Paths.swift +++ b/ArtemisKit/Sources/Navigation/Paths.swift @@ -27,7 +27,7 @@ public struct ExercisePath: Hashable { public let exercise: Exercise? public let coursePath: CoursePath - init(id: Int, coursePath: CoursePath) { + public init(id: Int, coursePath: CoursePath) { self.id = id self.exercise = nil self.coursePath = coursePath @@ -45,7 +45,7 @@ public struct LecturePath: Hashable { public let lecture: Lecture? public let coursePath: CoursePath - init(id: Int, coursePath: CoursePath) { + public init(id: Int, coursePath: CoursePath) { self.id = id self.lecture = nil self.coursePath = coursePath