import React, { createContext, useReducer, } from 'react'; import Users from '/imports/api/users'; import Auth from '/imports/ui/services/auth'; import Storage from '/imports/ui/services/storage/session'; import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger'; import UserService from '/imports/ui/components/user-list/service'; const CHAT_CONFIG = Meteor.settings.public.chat; const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id; const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id; const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system; const CHAT_EXPORTED_PRESENTATION_MESSAGE = CHAT_CONFIG.system_messages_keys .chat_exported_presentation; const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result; const CLOSED_CHAT_LIST_KEY = 'closedChatList'; export const ACTIONS = { TEST: 'test', ADDED: 'added', CHANGED: 'changed', REMOVED: 'removed', LAST_READ_MESSAGE_TIMESTAMP_CHANGED: 'last_read_message_timestamp_changed', INIT: 'initial_structure', SYNC_STATUS: 'sync_status', HAS_MESSAGE_TO_SYNC: 'has_message_to_sync', CLEAR_ALL: 'clear_all', CLEAR_STREAM_MESSAGES: 'clear_stream_messages', }; export const MESSAGE_TYPES = { //messages before user login, synced via makecall HISTORY: 'history', // messages after user login, synced via subscription STREAM: 'stream', }; export const getGroupingTime = () => Meteor.settings.public.chat.grouping_messages_window; export const getGroupChatId = () => Meteor.settings.public.chat.public_group_id; export const getLoginTime = () => (Users.findOne({ userId: Auth.userID }) || {}).authTokenValidatedTime || 0; const generateTimeWindow = (timestamp) => { const groupingTime = getGroupingTime(); const dateInMilliseconds = Math.floor(timestamp); const groupIndex = Math.floor(dateInMilliseconds / groupingTime) const date = groupIndex * 30000; return date; } export const ChatContext = createContext(); const removedMessagesReadState = {}; const generateStateWithNewMessage = (msg, state, msgType = MESSAGE_TYPES.HISTORY) => { const timeWindow = generateTimeWindow(msg.timestamp); const userId = msg.sender; const keyName = userId + '-' + timeWindow; const msgBuilder = (msg, chat) => { const msgTimewindow = generateTimeWindow(msg.timestamp); const key = msg.sender + '-' + msgTimewindow; const chatIndex = chat?.chatIndexes[key]; const { _id, ...restMsg } = msg; const indexValue = chatIndex ? (chatIndex + 1) : 1; const messageKey = key + '-' + indexValue; const tempGroupMessage = { [messageKey]: { ...restMsg, key: messageKey, lastTimestamp: msg.timestamp, read: msg.chatId === PUBLIC_CHAT_KEY && msg.timestamp <= getLoginTime() ? true : !!removedMessagesReadState[msg.id], content: [ { id: msg.id, text: msg.message, time: msg.timestamp }, ], } }; return [tempGroupMessage, msg.sender, indexValue, msg.senderName]; }; let stateMessages = state[msg.chatId]; if (!stateMessages) { if (msg.chatId === getGroupChatId()) { state[msg.chatId] = { count: 0, chatIndexes: {}, preJoinMessages: {}, posJoinMessages: {}, synced: true, unreadTimeWindows: new Set(), unreadCount: 0, }; } else { state[msg.chatId] = { count: 0, lastSender: '', lastSenderName: '', synced: true, chatIndexes: {}, messageGroups: {}, unreadTimeWindows: new Set(), unreadCount: 0, }; stateMessages = state[msg.chatId]; } stateMessages = state[msg.chatId]; } const forPublicChat = msg.timestamp < getLoginTime() ? stateMessages.preJoinMessages : stateMessages.posJoinMessages; const forPrivateChat = stateMessages.messageGroups; const messageGroups = msg.chatId === getGroupChatId() ? forPublicChat : forPrivateChat; const timewindowIndex = stateMessages.chatIndexes[keyName]; const groupMessage = messageGroups[keyName + '-' + timewindowIndex]; const fromSameSender = groupMessage && groupMessage.sender !== stateMessages.lastSender && groupMessage.senderName !== stateMessages.lastSenderName; if (!groupMessage || fromSameSender || msg.id.startsWith(SYSTEM_CHAT_TYPE)) { const [tempGroupMessage, sender, newIndex, senderName] = msgBuilder(msg, stateMessages); stateMessages.lastSender = sender; stateMessages.lastSenderName = senderName; stateMessages.chatIndexes[keyName] = newIndex; stateMessages.lastTimewindow = keyName + '-' + newIndex; ChatLogger.trace('ChatContext::formatMsg::msgBuilder::tempGroupMessage', tempGroupMessage); const messageGroupsKeys = Object.keys(tempGroupMessage); messageGroupsKeys.forEach(key => { messageGroups[key] = tempGroupMessage[key]; const message = tempGroupMessage[key]; message.messageType = msgType; const previousMessage = message.timestamp <= getLoginTime(); const amIPresenter = UserService.isUserPresenter(Auth.userID); const shouldAddPresentationExportMessage = message.id .includes(CHAT_EXPORTED_PRESENTATION_MESSAGE) && !amIPresenter; const shouldAddPollResultMessage = message.id .includes(CHAT_POLL_RESULTS_MESSAGE) && !amIPresenter; if ( !previousMessage && message.sender !== Auth.userID && (!message.id.startsWith(SYSTEM_CHAT_TYPE) || shouldAddPollResultMessage || shouldAddPresentationExportMessage) && !message.read ) { stateMessages.unreadTimeWindows.add(key); } }); } else { if (groupMessage) { if (groupMessage.sender === stateMessages.lastSender) { const previousMessage = msg.timestamp <= getLoginTime(); const timeWindowKey = keyName + '-' + stateMessages.chatIndexes[keyName]; const read = previousMessage ? true : !!removedMessagesReadState[groupMessage.id]; messageGroups[timeWindowKey] = { ...groupMessage, lastTimestamp: msg.timestamp, read, content: [ ...groupMessage.content, { id: msg.id, text: msg.message, time: msg.timestamp } ], }; if (!read && groupMessage.sender !== Auth.userID) { stateMessages.unreadTimeWindows.add(timeWindowKey); } } } } return state; } const reducer = (state, action) => { switch (action.type) { case ACTIONS.TEST: { ChatLogger.debug(ACTIONS.TEST); return { ...state, ...action.value, }; } case ACTIONS.ADDED: { ChatLogger.debug(ACTIONS.ADDED); const batchMsgs = action.value; const closedChatsToOpen = new Set(); const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || []; const newState = batchMsgs.reduce((acc, i) => { const message = i; const chatId = message.chatId; const chatClosedTimestamp = currentClosedChats.find(closedChat => closedChat.chatId === chatId)?.timestamp; if ( chatId !== PUBLIC_GROUP_CHAT_KEY && chatClosedTimestamp && message.timestamp > chatClosedTimestamp ) { closedChatsToOpen.add(chatId) } return generateStateWithNewMessage(message, acc, action.messageType); }, state); if (closedChatsToOpen.size) { const closedChats = currentClosedChats.filter(closedChat => !closedChatsToOpen.has(closedChat.chatId)); Storage.setItem(CLOSED_CHAT_LIST_KEY, closedChats); } // const newState = generateStateWithNewMessage(action.value, state); return { ...newState }; } case ACTIONS.CHANGED: { return { ...state, ...action.value, }; } case ACTIONS.REMOVED: { ChatLogger.debug(ACTIONS.REMOVED); if (state[PUBLIC_GROUP_CHAT_KEY]) { state[PUBLIC_GROUP_CHAT_KEY] = { count: 0, lastSender: '', chatIndexes: {}, syncing: false, preJoinMessages: {}, posJoinMessages: {}, unreadTimeWindows: new Set(), unreadCount: 0, }; } return state; } case ACTIONS.LAST_READ_MESSAGE_TIMESTAMP_CHANGED: { ChatLogger.debug(ACTIONS.LAST_READ_MESSAGE_TIMESTAMP_CHANGED); const { timestamp, chatId } = action.value; const newState = { ...state, }; const selectedChatId = chatId === PUBLIC_CHAT_KEY ? PUBLIC_GROUP_CHAT_KEY : chatId; const chat = state[selectedChatId]; if (!chat) return state; ['posJoinMessages', 'preJoinMessages', 'messageGroups'].forEach(messageGroupName => { const messageGroup = chat[messageGroupName]; if (messageGroup) { const timeWindowsids = Object.keys(messageGroup); timeWindowsids.forEach(timeWindowId => { const timeWindow = messageGroup[timeWindowId]; if (timeWindow) { if (!timeWindow.read) { if (timeWindow.lastTimestamp <= timestamp) { newState[selectedChatId].unreadTimeWindows.delete(timeWindowId); newState[selectedChatId][messageGroupName][timeWindowId] = { ...timeWindow, read: true, }; newState[selectedChatId] = { ...newState[selectedChatId], }; newState[selectedChatId][messageGroupName] = { ...newState[selectedChatId][messageGroupName], }; newState[chatId === PUBLIC_CHAT_KEY ? PUBLIC_GROUP_CHAT_KEY : chatId][messageGroupName][timeWindowId] = { ...newState[selectedChatId][messageGroupName][timeWindowId], }; } } } }); } }); return newState; } case ACTIONS.INIT: { ChatLogger.debug(ACTIONS.INIT); const { chatId } = action; const newState = { ...state }; if (!newState[chatId]) { newState[chatId] = { count: 0, lastSender: '', chatIndexes: {}, messageGroups: {}, unreadTimeWindows: new Set(), unreadCount: 0, }; } return state; } case ACTIONS.SYNC_STATUS: { ChatLogger.debug(ACTIONS.SYNC_STATUS); const newState = { ...state }; newState[action.value.chatId].syncedPercent = action.value.percentage; newState[action.value.chatId].syncing = action.value.percentage < 100 ? true : false; return newState; } case ACTIONS.CLEAR_ALL: { ChatLogger.debug(ACTIONS.CLEAR_ALL); const newState = { ...state }; const chatIds = Object.keys(newState); chatIds.forEach((chatId) => { newState[chatId] = chatId === PUBLIC_GROUP_CHAT_KEY ? { count: 0, lastSender: '', chatIndexes: {}, preJoinMessages: {}, posJoinMessages: {}, syncing: false, syncedPercent: 0, unreadTimeWindows: new Set(), unreadCount: 0, } : { count: 0, lastSender: '', chatIndexes: {}, messageGroups: {}, syncing: false, syncedPercent: 0, unreadTimeWindows: new Set(), unreadCount: 0, }; }); return newState; } // BBB don't remove individual messages, so when a message is removed it means the chat is cleared ( by admin, or for resync ) // considering it, we remove all messages from all chats case ACTIONS.CLEAR_STREAM_MESSAGES: { ChatLogger.debug(ACTIONS.CLEAR_STREAM_MESSAGES); const newState = { ...state }; const chatIds = Object.keys(newState); chatIds.forEach((chatId) => { const chat = newState[chatId]; ['posJoinMessages', 'messageGroups'].forEach((group) => { const messages = chat[group]; if (messages) { const timeWindowIds = Object.keys(messages); timeWindowIds.forEach((timeWindowId) => { const timeWindow = messages[timeWindowId]; if (timeWindow.messageType === MESSAGE_TYPES.STREAM) { chat.unreadTimeWindows.delete(timeWindowId); removedMessagesReadState[newState[chatId][group][timeWindowId].id] = newState[chatId][group][timeWindowId].read; delete newState[chatId][group][timeWindowId]; } }); } }) }); return newState; } default: { throw new Error(`Unexpected action: ${JSON.stringify(action)}`); } } }; export const ChatContextProvider = (props) => { const [chatContextState, chatContextDispatch] = useReducer(reducer, {}); ChatLogger.debug('dispatch', chatContextDispatch); return ( {props.children} ); } export const ContextConsumer = Component => props => ( {contexts => } ); export default { ContextConsumer, ChatContextProvider, }