diff --git a/Projects/Chat/Sources/ChatStore.swift b/Projects/Chat/Sources/ChatStore.swift index 62d8b8bbb0..6072f8a3cb 100644 --- a/Projects/Chat/Sources/ChatStore.swift +++ b/Projects/Chat/Sources/ChatStore.swift @@ -2,13 +2,18 @@ import Foundation import PresentableStore import hCore +public typealias ConverastionId = String public struct ChatState: StateProtocol { public init() {} + @Transient(defaultValue: false) var askedForPushNotificationsPermission: Bool + public var failedMessages: [ConverastionId: [Message]] = [:] } public enum ChatAction: ActionProtocol { case checkPushNotificationStatus + case setFailedMessage(conversationId: String, message: Message) + case clearFailedMessage(conversationId: String, message: Message) } final public class ChatStore: StateStore { @@ -23,6 +28,18 @@ final public class ChatStore: StateStore { switch action { case .checkPushNotificationStatus: newState.askedForPushNotificationsPermission = true + case let .setFailedMessage(conversationId, message): + var failedMessages = state.failedMessages + var messages = failedMessages[conversationId] ?? [] + messages.append(message) + failedMessages[conversationId] = messages + newState.failedMessages = failedMessages + case let .clearFailedMessage(conversationId, message): + var failedMessages = state.failedMessages + if let index = failedMessages[conversationId]?.firstIndex(where: { $0.id == message.id }) { + failedMessages[conversationId]?.remove(at: index) + } + newState.failedMessages = failedMessages } return newState } diff --git a/Projects/Chat/Sources/Models/Chat.swift b/Projects/Chat/Sources/Models/Chat.swift index eff6143218..07f40721d2 100644 --- a/Projects/Chat/Sources/Models/Chat.swift +++ b/Projects/Chat/Sources/Models/Chat.swift @@ -2,6 +2,7 @@ import Foundation import hCore public struct ChatData { + let conversationId: String let hasPreviousMessage: Bool let messages: [Message] let banner: Markdown? diff --git a/Projects/Chat/Sources/Models/Message.swift b/Projects/Chat/Sources/Models/Message.swift index 41045a67b1..aa29c61210 100644 --- a/Projects/Chat/Sources/Models/Message.swift +++ b/Projects/Chat/Sources/Models/Message.swift @@ -56,6 +56,11 @@ public struct Message: Identifiable, Codable, Hashable { func asFailed(with error: String) -> Message { return Message(localId: UUID().uuidString, type: type, date: sentAt, status: .failed(error: error)) } + + func copyWith(type: MessageType) -> Message { + return Message(localId: localId, type: type, date: sentAt, status: status) + } + } enum MessageSender: Codable, Hashable { diff --git a/Projects/Chat/Sources/Service/ChatService.swift b/Projects/Chat/Sources/Service/ChatService.swift index eaf676e19f..1de39e88c0 100644 --- a/Projects/Chat/Sources/Service/ChatService.swift +++ b/Projects/Chat/Sources/Service/ChatService.swift @@ -5,14 +5,12 @@ import hCoreUI import hGraphQL public protocol ChatServiceProtocol { - var type: ChatServiceType { get } func getNewMessages() async throws -> ChatData func getPreviousMessages() async throws -> ChatData func send(message: Message) async throws -> Message } public class ConversationService: ChatServiceProtocol { - public var type: ChatServiceType = .conversation @Inject var client: ConversationClient @PresentableStore var store: ChatStore @@ -36,6 +34,7 @@ public class ConversationService: ChatServiceProtocol { } newerToken = data.newerToken return .init( + conversationId: conversationId, hasPreviousMessage: olderToken != nil, messages: data.messages, banner: data.banner, @@ -55,6 +54,7 @@ public class ConversationService: ChatServiceProtocol { ) self.olderToken = data.olderToken return .init( + conversationId: conversationId, hasPreviousMessage: olderToken != nil, messages: data.messages, banner: data.banner, @@ -72,7 +72,6 @@ public class ConversationService: ChatServiceProtocol { } public class NewConversationService: ChatServiceProtocol { - public var type: ChatServiceType = .conversation @Inject var conversationsClient: ConversationsClient private var conversationService: ConversationService? private var generatingConversation = false @@ -87,6 +86,7 @@ public class NewConversationService: ChatServiceProtocol { return try await conversationService.getNewMessages() } return .init( + conversationId: "", hasPreviousMessage: false, messages: [], banner: nil, @@ -103,6 +103,7 @@ public class NewConversationService: ChatServiceProtocol { return try await conversationService.getPreviousMessages() } return .init( + conversationId: "", hasPreviousMessage: false, messages: [], banner: nil, diff --git a/Projects/Chat/Sources/Service/OctopusImplementation/ChatUploadFileClientOctopus.swift b/Projects/Chat/Sources/Service/OctopusImplementation/ChatUploadFileClientOctopus.swift index e6e2ceea40..950d1c3bfc 100644 --- a/Projects/Chat/Sources/Service/OctopusImplementation/ChatUploadFileClientOctopus.swift +++ b/Projects/Chat/Sources/Service/OctopusImplementation/ChatUploadFileClientOctopus.swift @@ -70,9 +70,17 @@ enum FileUploadRequest { let url = URL(string: baseUrlString)! let multipartFormDataRequest = MultipartFormDataRequest(url: url) for file in files { - guard case let .localFile(url, _) = file.source, - let data = try? Data(contentsOf: url) /*FileManager.default.contents(atPath: url.path)*/ - else { throw NetworkError.badRequest(message: nil) } + let data: Data? = { + switch file.source { + case .data(let data): + return data + case .localFile(let url, _): + return try? Data(contentsOf: url) + case .url: + return nil + } + }() + guard let data else { throw NetworkError.badRequest(message: nil) } multipartFormDataRequest.addDataField( fieldName: "files", fileName: file.name, diff --git a/Projects/Chat/Sources/Views/ChatFileView.swift b/Projects/Chat/Sources/Views/ChatFileView.swift index 42196204f1..392f6992e1 100644 --- a/Projects/Chat/Sources/Views/ChatFileView.swift +++ b/Projects/Chat/Sources/Views/ChatFileView.swift @@ -5,16 +5,29 @@ import hCoreUI struct ChatFileView: View { let file: File + let status: MessageStatus @EnvironmentObject var chatNavigationVm: ChatNavigationViewModel - init(file: File) { + init(file: File, status: MessageStatus = .draft) { self.file = file + self.status = status } let processor = DownsamplingImageProcessor( size: CGSize(width: 300, height: 300) ) @ViewBuilder var body: some View { + if case .failed = status { + fileView + } else { + fileView + .onTapGesture { + showFile() + } + } + } + + private var fileView: some View { Group { if file.mimeType.isImage { imageView @@ -22,17 +35,14 @@ struct ChatFileView: View { otherFile } } - .onTapGesture { - showFile() - } } @ViewBuilder var imageView: some View { - if file.mimeType == .GIF { + if file.mimeType == .GIF, let url = file.url { KFAnimatedImage( source: Kingfisher.Source.network( - Kingfisher.KF.ImageResource(downloadURL: file.url, cacheKey: file.id) + Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: file.id) ) ) .targetCache(ImageCache.default) @@ -82,6 +92,8 @@ struct ChatFileView: View { chatNavigationVm.isFilePresented = .init(url: url) case .url(let url): chatNavigationVm.isFilePresented = .init(url: url) + case .data(let data): + break } } @@ -93,8 +105,11 @@ struct ChatFileView: View { return Kingfisher.Source.network( Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: file.id) ) + case .data(let data): + return Kingfisher.Source.provider(InMemoryImageDataProvider(cacheKey: file.id, data: data)) } } + } #Preview{ @@ -135,3 +150,28 @@ struct ChatFileView: View { Spacer() } } + +public struct InMemoryImageDataProvider: ImageDataProvider { + + public var cacheKey: String + let data: Data + /// Provides the data which represents image. Kingfisher uses the data you pass in the + /// handler to process images and caches it for later use. + /// + /// - Parameter handler: The handler you should call when you prepared your data. + /// If the data is loaded successfully, call the handler with + /// a `.success` with the data associated. Otherwise, call it + /// with a `.failure` and pass the error. + /// + /// - Note: + /// If the `handler` is called with a `.failure` with error, a `dataProviderError` of + /// `ImageSettingErrorReason` will be finally thrown out to you as the `KingfisherError` + /// from the framework. + public func data(handler: @escaping (Result) -> Void) { + handler(.success(data)) + } + + /// The content URL represents this provider, if exists. + public var contentURL: URL? + +} diff --git a/Projects/Chat/Sources/Views/ChatScreen.swift b/Projects/Chat/Sources/Views/ChatScreen.swift index 1faa864814..0a55813b45 100644 --- a/Projects/Chat/Sources/Views/ChatScreen.swift +++ b/Projects/Chat/Sources/Views/ChatScreen.swift @@ -92,7 +92,7 @@ public struct ChatScreen: View { Spacer() } VStack(alignment: message.sender == .hedvig ? .leading : .trailing, spacing: 4) { - MessageView(message: message, conversationStatus: conversationStatus) + MessageView(message: message, conversationStatus: conversationStatus, vm: vm) .frame( maxWidth: 300, alignment: message.sender == .member ? .trailing : .leading diff --git a/Projects/Chat/Sources/Views/ChatScreenViewModel.swift b/Projects/Chat/Sources/Views/ChatScreenViewModel.swift index 7e0f3a619b..5573add6f5 100644 --- a/Projects/Chat/Sources/Views/ChatScreenViewModel.swift +++ b/Projects/Chat/Sources/Views/ChatScreenViewModel.swift @@ -14,6 +14,7 @@ public class ChatScreenViewModel: ObservableObject { @Published var banner: Markdown? @Published var conversationStatus: ConversationStatus = .open @Published var shouldShowBanner = true + private var conversationId: String? var chatInputVm: ChatInputViewModel = .init() @Published var title: String = L10n.chatTitle @Published var subTitle: String? @@ -30,7 +31,7 @@ public class ChatScreenViewModel: ObservableObject { private var openDeepLinkObserver: NSObjectProtocol? private var onTitleTap: (String) -> Void? private var claimId: String? - + private var sendingMessagesIds = [String]() public init( chatService: ChatServiceProtocol, onTitleTap: @escaping (String) -> Void = { claimId in } @@ -132,16 +133,27 @@ public class ChatScreenViewModel: ObservableObject { @MainActor private func fetchMessages() async { do { + let store: ChatStore = globalPresentableStoreContainer.get() let chatData = try await chatService.getNewMessages() + self.conversationId = chatData.conversationId let newMessages = chatData.messages.filterNotAddedIn(list: addedMessagesIds) withAnimation { self.messages.append(contentsOf: newMessages) + + if hasNext == nil { + if let conversationId, let failedMessages = store.state.failedMessages[conversationId] { + self.messages.insert(contentsOf: failedMessages.reversed(), at: 0) + addedMessagesIds.append(contentsOf: failedMessages.compactMap({ $0.id })) + } + } + self.messages.sort(by: { $0.sentAt > $1.sentAt }) self.lastDeliveredMessage = self.messages.first(where: { $0.sender == .member && $0.remoteId != nil }) } self.banner = chatData.banner self.conversationStatus = chatData.conversationStatus ?? .open addedMessagesIds.append(contentsOf: newMessages.compactMap({ $0.id })) + if hasNext == nil { hasNext = chatData.hasPreviousMessage } @@ -168,12 +180,18 @@ public class ChatScreenViewModel: ObservableObject { } private func sendToClient(message: Message) async { - do { - let sentMessage = try await chatService.send(message: message) - await handleSuccessAdding(for: sentMessage, to: message) - haveSentAMessage = true - } catch let ex { - await handleSendFail(for: message, with: ex.localizedDescription) + if !sendingMessagesIds.contains(message.id) { + sendingMessagesIds.append(message.id) + do { + let sentMessage = try await chatService.send(message: message) + let store: ChatStore = globalPresentableStoreContainer.get() + store.send(.clearFailedMessage(conversationId: conversationId ?? "", message: message)) + await handleSuccessAdding(for: sentMessage, to: message) + haveSentAMessage = true + } catch let ex { + await handleSendFail(for: message, with: ex.localizedDescription) + } + sendingMessagesIds.removeAll(where: { $0 == message.id }) } } @@ -221,6 +239,8 @@ public class ChatScreenViewModel: ObservableObject { } case .url: break + case .data: + break } } default: @@ -244,6 +264,14 @@ public class ChatScreenViewModel: ObservableObject { } } + func deleteFailed(message: Message) { + let store: ChatStore = globalPresentableStoreContainer.get() + withAnimation { + self.messages.removeAll(where: { $0.id == message.id }) + } + store.send(.clearFailedMessage(conversationId: conversationId ?? "", message: message)) + } + @MainActor private func handleSendFail(for message: Message, with error: String) { if let index = messages.firstIndex(where: { $0.id == message.id }) { @@ -254,16 +282,21 @@ public class ChatScreenViewModel: ObservableObject { break default: messages[index] = newMessage + let store: ChatStore = globalPresentableStoreContainer.get() + switch newMessage.type { + case .file(let file): + if let newFile = file.getAsDataFromUrl() { + let fileMessage = newMessage.copyWith(type: .file(file: newFile)) + store.send(.setFailedMessage(conversationId: conversationId ?? "", message: fileMessage)) + } + default: + store.send(.setFailedMessage(conversationId: conversationId ?? "", message: newMessage)) + } } } } } -public enum ChatServiceType { - case conversation - case oldChat -} - enum ConversationsError: Error { case errorMesage(message: String) case missingData diff --git a/Projects/Chat/Sources/Views/MessageView.swift b/Projects/Chat/Sources/Views/MessageView.swift index 58fb4279f0..f1cf4f4aab 100644 --- a/Projects/Chat/Sources/Views/MessageView.swift +++ b/Projects/Chat/Sources/Views/MessageView.swift @@ -8,23 +8,37 @@ import hCoreUI struct MessageView: View { let message: Message let conversationStatus: ConversationStatus - + @ObservedObject var vm: ChatScreenViewModel @State var height: CGFloat = 0 @State var width: CGFloat = 0 + @State var showRetryOptions = false @ViewBuilder public var body: some View { - HStack { + HStack(spacing: 0) { messageContent - .padding(.horizontal, message.horizontalPadding) - .padding(.vertical, message.verticalPadding) - .background(message.bgColor(conversationStatus: conversationStatus)) - .clipShape(RoundedRectangle(cornerRadius: 12)) .environment(\.colorScheme, .light) if case .failed = message.status { hCoreUIAssets.infoFilled.view .resizable() - .frame(width: 16, height: 16) + .frame(width: 24, height: 24) .foregroundColor(hSignalColor.Red.element) + .padding(.leading, .padding8) + .padding(.vertical, .padding24) + .onTapGesture { + showRetryOptions = true + } + } + } + .confirmationDialog("", isPresented: $showRetryOptions, titleVisibility: .hidden) { [weak vm] in + Button(L10n.generalRetry) { + Task { + await vm?.retrySending(message: message) + } + } + Button(L10n.General.remove, role: .destructive) { + vm?.deleteFailed(message: message) + } + Button(L10n.generalCancelButton, role: .cancel) { } } } @@ -38,39 +52,12 @@ struct MessageView: View { .frame(width: 24, height: 24) .foregroundColor(hSignalColor.Red.element) } - switch message.type { - case let .text(text): - MarkdownView( - config: .init( - text: text, - fontStyle: .body1, - color: hTextColor.Opaque.primary, - linkColor: hTextColor.Opaque.primary, - linkUnderlineStyle: .thick, - maxWidth: 300, - onUrlClicked: { url in - NotificationCenter.default.post(name: .openDeepLink, object: url) - } - ) - ) - .environment(\.colorScheme, .light) - - case let .file(file): - ChatFileView(file: file).frame(maxHeight: 200) - case let .crossSell(url): - LinkView(vm: .init(url: url)) - case let .deepLink(url): - if let type = DeepLink.getType(from: url) { - Button { - NotificationCenter.default.post(name: .openDeepLink, object: url) - } label: { - Text(type.title(displayText: url.contractName ?? type.importantText)) - .multilineTextAlignment(.leading) - } - } else { + Group { + switch message.type { + case let .text(text): MarkdownView( config: .init( - text: url.absoluteString, + text: text, fontStyle: .body1, color: hTextColor.Opaque.primary, linkColor: hTextColor.Opaque.primary, @@ -82,14 +69,48 @@ struct MessageView: View { ) ) .environment(\.colorScheme, .light) + + case let .file(file): + ChatFileView(file: file, status: message.status).frame(maxHeight: 200) + case let .crossSell(url): + LinkView(vm: .init(url: url)) + case let .deepLink(url): + if let type = DeepLink.getType(from: url) { + Button { + NotificationCenter.default.post(name: .openDeepLink, object: url) + } label: { + Text(type.title(displayText: url.contractName ?? type.importantText)) + .multilineTextAlignment(.leading) + } + } else { + MarkdownView( + config: .init( + text: url.absoluteString, + fontStyle: .body1, + color: hTextColor.Opaque.primary, + linkColor: hTextColor.Opaque.primary, + linkUnderlineStyle: .thick, + maxWidth: 300, + onUrlClicked: { url in + NotificationCenter.default.post(name: .openDeepLink, object: url) + } + ) + ) + .environment(\.colorScheme, .light) + } + case let .otherLink(url): + LinkView( + vm: .init(url: url) + ) + case .unknown: Text("") } - case let .otherLink(url): - LinkView( - vm: .init(url: url) - ) - case .unknown: Text("") } + .padding(.horizontal, message.horizontalPadding) + .padding(.vertical, message.verticalPadding) + .background(message.bgColor(conversationStatus: conversationStatus)) + .clipShape(RoundedRectangle(cornerRadius: 12)) } + } } diff --git a/Projects/Claims/Sources/Service/OctopusImplementation/UploadClaimFileClientOctopus.swift b/Projects/Claims/Sources/Service/OctopusImplementation/UploadClaimFileClientOctopus.swift index 2ea4d128c1..daed91ad15 100644 --- a/Projects/Claims/Sources/Service/OctopusImplementation/UploadClaimFileClientOctopus.swift +++ b/Projects/Claims/Sources/Service/OctopusImplementation/UploadClaimFileClientOctopus.swift @@ -49,6 +49,17 @@ extension NetworkClient: hClaimFileUploadClient { } case .url(_): break + case .data(let data): + if MimeType.findBy(mimeType: file.element.mimeType).isImage, + let image = UIImage(data: data) + { + let processor = DownsamplingImageProcessor( + size: CGSize(width: 300, height: 300) + ) + var options = KingfisherParsedOptionsInfo.init(nil) + options.processor = processor + ImageCache.default.store(image, forKey: file.element.fileId, options: options) + } } } diff --git a/Projects/Claims/Sources/Views/FilesGridView.swift b/Projects/Claims/Sources/Views/FilesGridView.swift index 32fe71b9cf..596d1652ad 100644 --- a/Projects/Claims/Sources/Views/FilesGridView.swift +++ b/Projects/Claims/Sources/Views/FilesGridView.swift @@ -75,6 +75,8 @@ struct FilesGridView: View { } case .url(let url): fileModel = .init(type: .url(url: url)) + case .data: + break } } } diff --git a/Projects/Contracts/Sources/View/ContractTable.swift b/Projects/Contracts/Sources/View/ContractTable.swift index 4978de04dc..cd1353a0e4 100755 --- a/Projects/Contracts/Sources/View/ContractTable.swift +++ b/Projects/Contracts/Sources/View/ContractTable.swift @@ -118,7 +118,9 @@ struct ContractTable: View { state.activeContracts } ) { activeContracts in - if !activeContracts.filter({ $0.typeOfContract.isHomeInsurance && !$0.isTerminated }).isEmpty { + if !activeContracts.filter({ $0.typeOfContract.isHomeInsurance && !$0.isTerminated }).isEmpty + && Dependencies.featureFlags().isMovingFlowEnabled + { hSection { InfoCard(text: L10n.insurancesTabMovingFlowInfoTitle, type: .campaign) .buttons([ diff --git a/Projects/hCore/Sources/Models/File.swift b/Projects/hCore/Sources/Models/File.swift index 0d7f1d40c2..644b13effc 100644 --- a/Projects/hCore/Sources/Models/File.swift +++ b/Projects/hCore/Sources/Models/File.swift @@ -17,17 +17,30 @@ public struct File: Codable, Equatable, Identifiable, Hashable { self.source = source } - public var url: URL { + public var url: URL? { switch source { case .localFile(let url, _): return url case .url(let url): return url + default: + return nil + } + } + + public func getAsDataFromUrl() -> File? { + guard let url else { + return nil + } + if let data = try? Data(contentsOf: url) { + return .init(id: id, size: size, mimeType: mimeType, name: name, source: .data(data: data)) } + return nil } } public enum FileSource: Codable, Equatable, Hashable { + case data(data: Data) case localFile(url: URL, thumbnailURL: URL?) case url(url: URL) } diff --git a/Projects/hCoreUI/Sources/Views/FileView.swift b/Projects/hCoreUI/Sources/Views/FileView.swift index e1a6b1f43d..471011e134 100644 --- a/Projects/hCoreUI/Sources/Views/FileView.swift +++ b/Projects/hCoreUI/Sources/Views/FileView.swift @@ -23,6 +23,14 @@ public struct FileView: View { imageFromLocalFile(url: thumbnailUrl ?? imageUrl) case .url(let url): imageFromRemote(url: url) + case let .data(data): + if let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .aspectRatio( + contentMode: .fill + ) + } } } else { GeometryReader { geometry in