import { PayloadAction, createSlice, isAnyOf } from '@reduxjs/toolkit' import { InitialChannelConversation } from 'api' import type { ChannelConversation, ServiceAttachEntrySettings, } from 'api/api.types' import { initializeApp, resetApp } from 'domains/app/actions' import { initializeConfig } from 'domains/config/actions' import type { ChannelEvent, CurrentUploadPayload, EntryMeta, MessageParticipant, ParticipantInfo, ServiceInfo, StoreState, } from 'domains/store/store.types' import { randomId } from 'lib/id' import { getTimeFromSeconds } from 'ui/utils/general-utils' import { entryTypes, eventTypes, featureKeys, payloadTypes, readStates, } from 'ui/utils/seamly-utils' export type AddEventPayload = ChannelEvent export const isUnreadMessage = ({ type, payload }: ChannelEvent) => document.visibilityState === 'hidden' || (type === eventTypes.message && !payload.fromClient) || (type === eventTypes.info && payload.type === payloadTypes.text) export const orderEvents = (events: ChannelEvent[]) => { return events.sort( ( { payload: { occurredAt: occurredAtA = 0 } }, { payload: { occurredAt: occurredAtB = 0 } }, ) => occurredAtA - occurredAtB, ) } export const mergeEvents = ( stateEvents: ChannelEvent[], conversationMessages: ChannelEvent[], ): ChannelEvent[] => { const newStateEvents = stateEvents.filter( (stateEvent) => stateEvent.type === 'message' && // Deduplicate the event streams, giving events in historyEvents // precedence so the server is able to push changes to events. !stateEvent.payload.optimisticallyInjected && !conversationMessages.some((message) => { return message.payload?.id === stateEvent.payload.id }), ) const newConversationMessages = conversationMessages .filter( ({ type, payload }) => // Remove all non displayable participant messages !(type === 'participant' && !payload?.participant?.introduction), ) // Reverse is done here because the server sends the history in the order // newest to oldest. In the case of exactly the same occurredAt timestamps // these messages will be shown in the wrong order if not reversed. For // the normal merging logic there is no added effect. .reverse() return orderEvents([...newConversationMessages, ...newStateEvents]) } const participantReducer = (participantInfo: ParticipantInfo, action) => { // TODO: a) Styleguide only! b) Should be removed after styleguide overhaul. if (!participantInfo) { return { participants: {}, currentAgent: '', } } const { participants } = participantInfo || { participants: {} } const { id, avatar, name, introduction } = action.participant const oldParticipant = participants[id] const newParticipants = { ...participants, [id]: oldParticipant ? { ...oldParticipant, ...(avatar ? { avatar } : {}), ...(name ? { name } : {}), ...(introduction ? { introduction } : {}), } : action.participant, } return { ...participantInfo, participants: newParticipants, currentAgent: participantInfo.currentAgent !== id && !action.fromClient ? id : participantInfo.currentAgent, } } export const calculateNewEntryMeta = ( entryMeta: StoreState['entryMeta'], channelEvent?: ChannelEvent, ): EntryMeta => { // Events originating from the client, and incoming events that are not messages, // should leave the entry meta as is. if ( channelEvent?.payload?.fromClient === true || channelEvent?.type !== 'message' ) { return entryMeta } const entry = channelEvent?.payload?.translatedEntry || channelEvent?.payload?.entry const { blockAutoEntrySwitch } = entryMeta const actions = channelEvent?.payload?.actions || {} const translatedActions = channelEvent?.payload?.translatedActions || {} const { type, options } = entry || {} let newActive: string | undefined = entryMeta.active if (!blockAutoEntrySwitch && type !== entryMeta.userSelected) { newActive = type } return { ...entryMeta, active: newActive, optionsOverride: { ...(type ? { [type]: options } : {}), }, actions, translatedActions, } } export const initialStoreState: StoreState = { events: [], isLastEventFromClient: false, initialState: { userResponded: false, context: { contentLocale: '', translationActive: false, userLocale: '', }, }, unreadEvents: 0, userHasResponded: false, loadedImageEventIds: [], isLoading: false, idleDetachCountdown: { hasCountdown: false, isActive: false, remaining: undefined, wasStopped: undefined, count: undefined, timer: undefined, }, resumeConversationPrompt: false, serviceInfo: { activeServiceSessionId: '', proactiveMessages: false, }, participantInfo: { participants: {}, currentAgent: '', }, headerTitles: { title: null, subTitle: '', }, historyLoaded: false, skiplinkElementId: randomId(), skiplinkTargetId: randomId(), optionsButtonId: randomId(), headerCollapseButtonId: randomId(), windowOpenButtonId: randomId(), serviceData: {}, options: { features: { webNotifications: { enabled: false, }, }, panelActive: false, optionActive: '', userSelectedOptions: {}, }, showFileUpload: false, currentUploads: [], processingFileUploads: [], entryMeta: { default: entryTypes.text, optionsOverride: {}, defaultEntry: entryTypes.text, active: entryTypes.text, userSelected: null, blockAutoEntrySwitch: false, options: {}, actions: {}, translatedActions: {}, }, seamlyContainerElement: null, } export const storeSlice = createSlice({ name: 'store', initialState: initialStoreState, reducers: { addEvent: (state, action: PayloadAction) => { const { type: eventType, payload } = action.payload const accountHasUploads = Object.prototype.hasOwnProperty.call( state.options.features, featureKeys.uploads, ) let newOptions = { ...state.options } // This enabled override of the service enabled value for uploads. // If a message is sent with entry of type upload it will temporarily // override service value and enable uploads. if ( accountHasUploads && (eventType === eventTypes.message || eventType === eventTypes.participant) && !payload.fromClient ) { const entryType = eventType === 'message' ? payload.entry?.type : undefined newOptions = { ...newOptions, features: { ...newOptions.features, uploads: { enabled: newOptions.features.uploads?.enabled || false, ...(newOptions.features?.uploads || {}), enabledFromEntry: entryType === entryTypes.upload, }, }, } } const incrementUnread = isUnreadMessage(action.payload) // We check for duplicated and ignore them as in some error of the websocket // a duplicate join can be active for a while until the server connection // times out. const eventExists = state.events.find((e) => e.payload.id === payload.id) if (eventExists) { return } const matchedEvent = state.events.find( (m) => 'transactionId' in m.payload && 'transactionId' in payload && m.payload.transactionId === payload.transactionId && (!action.payload.type || m.type === action.payload.type) && payload.fromClient, ) if (!matchedEvent) { const newEntryMeta = calculateNewEntryMeta( state.entryMeta, action.payload, ) state.entryMeta = !accountHasUploads && newEntryMeta.active === entryTypes.upload ? { ...state.entryMeta } : newEntryMeta state.options = newOptions if (incrementUnread) { state.unreadEvents += 1 if (eventType !== 'service_data') { action.payload.payload.messageStatus = payload.fromClient ? readStates.read : readStates.received } } action.payload.payload.key = randomId() state.events.push(action.payload) state.events = orderEvents(state.events) } if (payload.fromClient) { state.isLastEventFromClient = payload.fromClient } }, updateEvent: (state, { payload }: PayloadAction) => { const event = state.events.find( (e) => e.type !== 'service_data' && payload.type !== 'service_data' && e.payload.transactionId === payload.payload.transactionId, ) if (event) { event.payload.id = payload.payload.id event.payload.occurredAt = payload.payload.occurredAt } else { state.events.push(payload) } state.events = orderEvents(state.events) }, clearEvents: (state) => { state.unreadEvents = 0 state.loadedImageEventIds = [] state.events = [] }, setEventsRead: (state, { payload }: PayloadAction) => { state.unreadEvents = 0 state.events.forEach((event) => { if (event.payload.id && payload.indexOf(event.payload.id) !== -1) { event.payload = { ...event.payload, ...(event.type !== 'service_data' && event.payload.messageStatus === readStates.received && { messageStatus: readStates.read, }), } } return event }) }, setLoadedImageEventIds: (state, { payload }) => { state.loadedImageEventIds.push(payload) }, setEvents: ( state, { payload: { messages, unreadMessageCount, userResponded, participants, service, serviceData, ui, }, }: PayloadAction, ) => { const events = mergeEvents(state.events, messages as ChannelEvent[]) const mergedParticipants: Record< string, ChannelConversation['participants'][0] > = Object.fromEntries( Object.entries(participants).map(([_, value]) => [value.id, value]), ) // Use the messages from the payload, as `events` only contains displayable ones. // The first participant message found is the 'last', as we receive them from newest to oldest. const lastParticipantEvent = messages.find( (m) => m.type === 'participant', ) // @ts-expect-error TypeScript incorrectly assumes that the payload can be any of info/message/participant const lastParticipantId = lastParticipantEvent?.payload?.participant?.id const { entry } = service?.settings || {} const historyNewEntryMeta = calculateNewEntryMeta( { ...state.entryMeta, ...entry, active: payloadTypes.text, // @ts-expect-error This only has part of the upload related options set options: { ...entry?.options }, }, // Only events of type 'message' can change the entry options events.findLast((event) => event.type === 'message'), ) let newFeatures = { ...state.options.features } // The first service message found is the 'last', as we receive them from newest to oldest. const lastServiceMessage = messages.find( (m) => !m.payload.fromClient && ['message', 'participant'].includes(m.type), ) const newFeaturesHasUpload = Object.prototype.hasOwnProperty.call( newFeatures, featureKeys.uploads, ) // Check for upload enabled by entry type if (newFeaturesHasUpload && lastServiceMessage?.type === 'message') { // @ts-expect-error TypeScript incorrectly assumes that the payload can be any of info/message/participant const entryType = lastServiceMessage.payload.entry?.type || '' newFeatures = { ...newFeatures, uploads: { enabled: newFeatures.uploads?.enabled || false, enabledFromEntry: entryType === entryTypes.upload, }, } } state.unreadEvents = unreadMessageCount state.userHasResponded = userResponded state.events = events.filter( (e) => e.type !== 'participant' || !!e.payload.participant?.introduction, ) state.participantInfo = { ...state.participantInfo, ...(lastParticipantId ? participantReducer(state.participantInfo, { participant: mergedParticipants[lastParticipantId], }) : {}), participants: mergedParticipants, } state.historyLoaded = true state.serviceInfo = { ...state.serviceInfo, proactiveMessages: service?.settings?.proactiveMessages?.enabled || false, activeServiceSessionId: service?.sessionId, } state.serviceData = { ...state.serviceData, ...serviceData, } as any state.options = { ...state.options, features: newFeatures, } state.entryMeta = !newFeaturesHasUpload && historyNewEntryMeta.active === entryTypes.upload ? { ...state.entryMeta } : historyNewEntryMeta state.resumeConversationPrompt = ui.resumeConversationPrompt || false if (lastParticipantId) { state.headerTitles.subTitle = mergedParticipants[lastParticipantId]?.name } }, setIsLoading: (state, { payload }: PayloadAction) => { state.isLoading = payload }, initIdleDetachCountdown: (state, { payload }) => { const { delaySeconds, delayTime } = payload state.idleDetachCountdown = { hasCountdown: true, isActive: true, wasStopped: false, count: delaySeconds, remaining: delaySeconds, timer: delayTime, } }, decrementIdleDetachCountdownCounter: (state) => { const { idleDetachCountdown } = state const { remaining: prevRemaining = 0 } = idleDetachCountdown const remaining = prevRemaining - 1 state.idleDetachCountdown.remaining = remaining state.idleDetachCountdown.timer = getTimeFromSeconds(remaining) }, stopIdleDetachCountdownCounter: (state) => { state.idleDetachCountdown.isActive = false state.idleDetachCountdown.wasStopped = true }, clearIdleDetachCountdown: (state) => { state.idleDetachCountdown.hasCountdown = false state.idleDetachCountdown.isActive = false }, initResumeConversationPrompt: (state) => { state.resumeConversationPrompt = true }, clearResumeConversationPrompt: (state) => { state.resumeConversationPrompt = false }, setParticipant: ( state, { payload }: PayloadAction>, ) => { state.participantInfo = participantReducer(state.participantInfo, { participant: payload.participant, fromClient: payload.fromClient, }) }, setActiveService: ( state, { payload }: PayloadAction, ) => { if (state.serviceInfo.activeServiceSessionId !== payload) { state.serviceInfo.activeServiceSessionId = payload } }, setHeaderTitle: (state, { payload }) => { state.headerTitles.title = payload }, setHeaderSubTitle: (state, { payload }) => { state.headerTitles.subTitle = payload }, setInitialState: ( state, { payload }: PayloadAction, ) => { state.initialState = payload state.unreadEvents = initialStoreState.unreadEvents }, setServiceDataItem: (state, { payload }) => { state.serviceData[payload.type] = payload }, setFeatures: (state, { payload }) => { if (!payload.features) { return } state.options.features = payload.features }, updateFeatures: (state, { payload }) => { Object.entries(payload).forEach(([featureKey, value]) => { state.options.features[featureKey] = value }) }, setFeatureEnabledState: (state, { payload }) => { if ( !Object.prototype.hasOwnProperty.call( state.options.features, payload.key, ) ) { return } state.options.features[payload.key].enabled = payload.enabled }, clearFeatures: (state) => { state.options.features = { webNotifications: state.options.features.webNotifications, } }, showOption: (state, { payload }) => { state.options.panelActive = true state.options.optionActive = payload }, hideOption: (state) => { state.options.panelActive = false state.options.optionActive = '' }, setUserSelectedOptions: (state, { payload }) => { state.options.userSelectedOptions = payload }, setUserSelectedOption: (state, { payload }) => { const { option, value } = payload state.options.userSelectedOptions[option] = value }, setBlockAutoEntrySwitch: (state, { payload }) => { state.entryMeta.blockAutoEntrySwitch = payload }, setServiceEntryMetadata: ( state, { payload }: PayloadAction, ) => { state.entryMeta.options = { ...state.entryMeta.options, // @ts-expect-error This only has part of the upload related options set upload: { ...payload.options.upload, }, } state.entryMeta.optionsOverride = {} state.entryMeta.actions = {} state.entryMeta.translatedActions = {} }, setActiveEntryType: (state, { payload }) => { state.entryMeta.active = payload }, setUserEntryType: (state, { payload }) => { state.entryMeta.userSelected = payload }, clearAbortTransaction: (state) => { state.entryMeta.actions = {} }, registerUpload: ( state, { payload }: PayloadAction>, ) => { state.currentUploads.push({ id: payload.fileId, name: payload.fileName, progress: 1, uploading: true, complete: false, error: '', uploadHandle: payload.uploadHandle, }) }, setUploadProgress: ( state, { payload, }: PayloadAction>, ) => { state.currentUploads = state.currentUploads.map((fileUpload) => { if (fileUpload.id === payload.fileId) { return { ...fileUpload, progress: payload.progress, uploading: payload.progress !== 100, uploadHandle: payload.progress === 100 ? null : fileUpload.uploadHandle, } } return fileUpload }) }, startProcessingImage: (state, { payload }: PayloadAction) => { state.processingFileUploads.push(payload) }, doneProcessingImage: (state, { payload }: PayloadAction) => { state.processingFileUploads = state.processingFileUploads.filter( (fileId) => fileId !== payload, ) }, setUploadError: (state, { payload }) => { state.currentUploads = state.currentUploads.map((fileUpload) => { if (fileUpload.id === payload.fileId) { return { ...fileUpload, error: payload.errorText, progress: 0, uploading: false, uploadHandle: null, } } return fileUpload }) }, setUploadComplete: ( state, { payload }: PayloadAction, ) => { state.currentUploads = state.currentUploads.map((fileUpload) => { if (fileUpload.id === payload) { return { ...fileUpload, complete: true, } } return fileUpload }) }, clearAllUploads: (state) => { state.currentUploads = [] }, setSeamlyContainerElement: (state, { payload }) => { state.seamlyContainerElement = payload }, setProactiveMessages: ( state, { payload }: PayloadAction, ) => { state.serviceInfo.proactiveMessages = payload }, }, extraReducers: (builder) => { builder .addCase(resetApp.pending, () => initialStoreState) .addCase(initializeApp.pending, (state) => { state.isLoading = true }) .addCase(initializeConfig.fulfilled, (state, { payload }) => { state.headerTitles.subTitle = payload.agentParticipant?.name || '' if (!payload.features) return state.options.features = payload.features }) .addCase(initializeApp.fulfilled, (state, { payload, type }) => { state.isLoading = false if (!payload.initialState) return state.initialState = payload.initialState if ('messages' in payload.initialState) { storeSlice.caseReducers.setEvents(state, { payload: payload.initialState, type, }) } const { initialState: { service }, } = payload if (service?.settings?.entry?.options?.upload) { storeSlice.caseReducers.setFeatureEnabledState(state, { payload: { key: featureKeys.uploads, enabled: service.settings.entry.options.upload.enabled || false, }, type, }) } }) .addMatcher( isAnyOf(initIdleDetachCountdown, initResumeConversationPrompt), (state) => { state.isLastEventFromClient = false }, ) }, }) export const { addEvent, updateEvent, clearAllUploads, clearEvents, clearFeatures, clearIdleDetachCountdown, clearResumeConversationPrompt, decrementIdleDetachCountdownCounter, hideOption, initIdleDetachCountdown, initResumeConversationPrompt, registerUpload, setActiveEntryType, setActiveService, setBlockAutoEntrySwitch, setEventsRead, setFeatureEnabledState, updateFeatures, setFeatures, setHeaderSubTitle, setHeaderTitle, setEvents, setInitialState, setIsLoading, setLoadedImageEventIds, setParticipant, setSeamlyContainerElement, setServiceDataItem, setServiceEntryMetadata, clearAbortTransaction, setUploadComplete, setUploadError, setUploadProgress, startProcessingImage, doneProcessingImage, setUserEntryType, setUserSelectedOption, setUserSelectedOptions, showOption, stopIdleDetachCountdownCounter, setProactiveMessages, } = storeSlice.actions export default storeSlice.reducer