AzureCommunicationUI/sdk/AzureCommunicationUIChat/Sources/Redux/Middleware/ChatActionHandler.swift (260 lines of code) (raw):
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
//
import AzureCommunicationCommon
import Foundation
protocol ChatActionHandling {
// MARK: LifeCycleHandler
@discardableResult
func enterBackground(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func enterForeground(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never>
// MARK: ChatActionHandler
@discardableResult
func onChatThreadDeleted(dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func disconnectChat(dispatch: @escaping ActionDispatch) -> Task<Void, Never>
// MARK: Participants Handler
// MARK: Repository Handler
@discardableResult
func initialize(state: ChatAppState,
dispatch: @escaping ActionDispatch,
serviceListener: ChatServiceEventHandling) -> Task<Void, Never>
@discardableResult
func getInitialMessages(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func getListOfParticipants(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func getPreviousMessages(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func sendMessage(internalId: String,
content: String,
state: ChatAppState,
dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func editMessage(messageId: String,
content: String,
prevContent: String,
state: ChatAppState,
dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func deleteMessage(messageId: String,
state: ChatAppState,
dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func sendTypingIndicator(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never>
@discardableResult
func sendReadReceipt(messageId: String, state: ChatAppState, dispatch: @escaping ActionDispatch)
-> Task<Void, Never>
func setTypingParticipantTimer(_ getState: @escaping () -> ChatAppState, _ dispatch: @escaping ActionDispatch)
}
class ChatActionHandler: ChatActionHandling {
private let chatService: ChatServiceProtocol
private let logger: Logger
private var timer: Timer?
private var connectEventHandler: ((Result<Void, ChatCompositeError>) -> Void)?
init(chatService: ChatServiceProtocol,
logger: Logger,
connectEventHandler: ((Result<Void, ChatCompositeError>) -> Void)?) {
self.chatService = chatService
self.logger = logger
self.connectEventHandler = connectEventHandler
}
func initialize(state: ChatAppState,
dispatch: @escaping ActionDispatch,
serviceListener: ChatServiceEventHandling) -> Task<Void, Never> {
Task {
do {
try await chatService.initialize()
serviceListener.subscription(dispatch: dispatch)
dispatch(.chatAction(.initializeChatSuccess))
connectEventHandler?(.success(Void()))
} catch {
connectEventHandler?(.failure(ChatCompositeError(
code: ChatCompositeErrorCode.joinFailed,
error: error)))
logger.error("Failed to initialize chat client due to error: \(error)")
dispatch(.chatAction(.initializeChatFailed(error: error)))
}
}
}
// MARK: LifeCycleHandler
func enterBackground(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
// Pause UI update
Task {
print("ChatActionHandler `enterBackground` not implemented")
}
}
func enterForeground(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
// rehydrate UI based on latest state, move to last unread message
Task {
print("ChatActionHandler `enterForeground` not implemented")
}
}
// MARK: Chat Handler
func sendTypingIndicator(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
do {
try await chatService.sendTypingIndicator()
dispatch(.chatAction(.sendTypingIndicatorSuccess))
} catch {
logger.error("ChatActionHandler sendTypingIndicator failed: \(error)")
dispatch(.chatAction(.sendTypingIndicatorFailed(error: error)))
}
}
}
func onChatThreadDeleted(dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
// may be extracted to function in the future
dispatch(.errorAction(.fatalErrorUpdated(internalError: .chatEvicted, error: nil)))
}
}
func disconnectChat(dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
do {
try await chatService.disconnectChatService()
dispatch(.chatAction(.disconnectChatSuccess))
} catch {
logger.error("ChatActionHandler disconnectChat failed: \(error)")
dispatch(.chatAction(.disconnectChatFailed(error: error)))
}
}
}
// MARK: Participants Handler
func sendReadReceipt(messageId: String, state: ChatAppState, dispatch: @escaping ActionDispatch)
-> Task<Void, Never> {
Task {
guard let lastReadReceiptSentTimestamp = state.chatState.lastReadReceiptSentTimestamp else {
await sendReadReceiptToChatService(messageId: messageId, dispatch: dispatch)
return
}
guard let messageTimestamp = messageId.convertEpochStringToTimestamp(),
messageTimestamp > lastReadReceiptSentTimestamp else {
return
}
await sendReadReceiptToChatService(messageId: messageId, dispatch: dispatch)
}
}
private func sendReadReceiptToChatService(messageId: String, dispatch: @escaping ActionDispatch) async {
do {
try await chatService.sendReadReceipt(messageId: messageId)
dispatch(.participantsAction(.sendReadReceiptSuccess(messageId: messageId)))
} catch {
logger.error("ChatActionHandler sendReadReceipt failed: \(error)")
dispatch(.participantsAction(.sendReadReceiptFailed(error: error as NSError)))
}
}
func setTypingParticipantTimer(_ getState: @escaping () -> ChatAppState,
_ dispatch: @escaping ActionDispatch) {
// If timer in progress, do nothing
guard timer == nil else {
return
}
// Otherwise, set up an initial timer with 8 seconds of timeout
scheduleTimer(timeInterval: UserEventTimestampModel.typingParticipantTimeout,
getState: getState,
dispatch: dispatch)
}
// MARK: Repository Handler
func getInitialMessages(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
do {
let initialMessages = try await chatService.getInitialMessages()
dispatch(.repositoryAction(.fetchInitialMessagesSuccess(messages: initialMessages)))
} catch {
dispatch(.repositoryAction(.fetchInitialMessagesFailed(error: error)))
}
}
}
func getListOfParticipants(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
do {
let maskedParticipantIds = try await chatService.getMaskedParticipantIds()
dispatch(.participantsAction(.maskedParticipantsReceived(participantIds: maskedParticipantIds)))
let listOfParticipants = try await chatService.getListOfParticipants()
dispatch(.participantsAction(.fetchListOfParticipantsSuccess(
participants: listOfParticipants,
localParticipantId: state.chatState.localUser?.id)))
} catch {
dispatch(.participantsAction(.fetchListOfParticipantsFailed(error: error)))
}
}
}
func getPreviousMessages(state: ChatAppState, dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
do {
let previousMessages = try await chatService.getPreviousMessages()
dispatch(.repositoryAction(.fetchPreviousMessagesSuccess(messages: previousMessages)))
} catch {
// dispatch error *not handled*
dispatch(.repositoryAction(.fetchPreviousMessagesFailed(error: error)))
}
}
}
func sendMessage(internalId: String,
content: String,
state: ChatAppState,
dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
do {
guard let displayName = state.chatState.localUser?.displayName else {
return
}
let actualId = try await chatService.sendMessage(
content: content,
senderDisplayName: displayName)
dispatch(.repositoryAction(.sendMessageSuccess(
internalId: internalId,
actualId: actualId)))
} catch {
dispatch(.repositoryAction(.sendMessageFailed(internalId: internalId, error: error)))
}
}
}
func editMessage(messageId: String,
content: String,
prevContent: String,
state: ChatAppState,
dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
do {
try await chatService.editMessage(messageId: messageId, content: content)
dispatch(.repositoryAction(.editMessageSuccess(messageId: messageId)))
} catch {
// dispatch error *not handled* to replace with prevContent
dispatch(.repositoryAction(
.editMessageFailed(messageId: messageId,
prevContent: prevContent,
error: error)))
}
}
}
func deleteMessage(messageId: String,
state: ChatAppState,
dispatch: @escaping ActionDispatch) -> Task<Void, Never> {
Task {
do {
try await chatService.deleteMessage(messageId: messageId)
dispatch(.repositoryAction(.deleteMessageSuccess(messageId: messageId)))
} catch {
// dispatch error *not handled*
dispatch(.repositoryAction(
.deleteMessageFailed(messageId: messageId,
error: error)))
}
}
}
}
// MARK: Helpers
extension ChatActionHandler {
// MARK: Typing Participant Helpers
private func scheduleTimer(timeInterval: TimeInterval,
getState: @escaping () -> ChatAppState,
dispatch: @escaping ActionDispatch) {
DispatchQueue.main.async {
self.timer = Timer.scheduledTimer(withTimeInterval: timeInterval,
repeats: false,
block: { [weak self] runningTimer in
self?.handleTimerInterval(runningTimer, dispatch, getState)
})
}
}
private func handleTimerInterval(_ timer: Timer,
_ dispatch: @escaping ActionDispatch,
_ getState: @escaping () -> ChatAppState) {
dispatch(.participantsAction(.clearIdleTypingParticipants))
// get next participant with expiring timestamp
let expiringParticipant = getState().participantsState.typingParticipants
.filter(\.isTyping)
.sorted(by: { lhs, rhs in
lhs.timestamp > rhs.timestamp
})
.first
// remove timer if there's no more typing participants
guard let expiringParticipant = expiringParticipant else {
self.timer = nil
return
}
// how many seconds left until participant to be removed
let expiringInSeconds = max(0, Date().timeIntervalSince(expiringParticipant.timestamp.value))
scheduleTimer(timeInterval: expiringInSeconds,
getState: getState,
dispatch: dispatch)
}
}