Skip to content

Commit

Permalink
Merge pull request #1509 from HedvigInsurance/improvement/chat/save-f…
Browse files Browse the repository at this point in the history
…ailed-messages

remove chat service type and add list of failed messages
  • Loading branch information
juliaendre authored Sep 20, 2024
2 parents 3809785 + 9d41df2 commit a6db6e1
Show file tree
Hide file tree
Showing 14 changed files with 232 additions and 70 deletions.
17 changes: 17 additions & 0 deletions Projects/Chat/Sources/ChatStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatState, ChatAction> {
Expand All @@ -23,6 +28,18 @@ final public class ChatStore: StateStore<ChatState, ChatAction> {
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
}
Expand Down
1 change: 1 addition & 0 deletions Projects/Chat/Sources/Models/Chat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation
import hCore

public struct ChatData {
let conversationId: String
let hasPreviousMessage: Bool
let messages: [Message]
let banner: Markdown?
Expand Down
5 changes: 5 additions & 0 deletions Projects/Chat/Sources/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions Projects/Chat/Sources/Service/ChatService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -36,6 +34,7 @@ public class ConversationService: ChatServiceProtocol {
}
newerToken = data.newerToken
return .init(
conversationId: conversationId,
hasPreviousMessage: olderToken != nil,
messages: data.messages,
banner: data.banner,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -87,6 +86,7 @@ public class NewConversationService: ChatServiceProtocol {
return try await conversationService.getNewMessages()
}
return .init(
conversationId: "",
hasPreviousMessage: false,
messages: [],
banner: nil,
Expand All @@ -103,6 +103,7 @@ public class NewConversationService: ChatServiceProtocol {
return try await conversationService.getPreviousMessages()
}
return .init(
conversationId: "",
hasPreviousMessage: false,
messages: [],
banner: nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 46 additions & 6 deletions Projects/Chat/Sources/Views/ChatFileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,44 @@ 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
} else {
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)
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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{
Expand Down Expand Up @@ -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<Data, Error>) -> Void) {
handler(.success(data))
}

/// The content URL represents this provider, if exists.
public var contentURL: URL?

}
2 changes: 1 addition & 1 deletion Projects/Chat/Sources/Views/ChatScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 45 additions & 12 deletions Projects/Chat/Sources/Views/ChatScreenViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 }
Expand Down Expand Up @@ -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
}
Expand All @@ -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 })
}
}

Expand Down Expand Up @@ -221,6 +239,8 @@ public class ChatScreenViewModel: ObservableObject {
}
case .url:
break
case .data:
break
}
}
default:
Expand All @@ -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 }) {
Expand All @@ -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
Expand Down
Loading

0 comments on commit a6db6e1

Please sign in to comment.