From 19df5c09d20391247af90bf6c31476321ff44e0a Mon Sep 17 00:00:00 2001 From: NeonDaniel <34697904+NeonDaniel@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:20:36 +0000 Subject: [PATCH] Updated Version and Dynamic Dependencies --- chat_client/static/js/klatchatNano.js | 5607 ++++++++++++------------- version.py | 4 +- 2 files changed, 2784 insertions(+), 2827 deletions(-) diff --git a/chat_client/static/js/klatchatNano.js b/chat_client/static/js/klatchatNano.js index 12bcb66b..459844b8 100644 --- a/chat_client/static/js/klatchatNano.js +++ b/chat_client/static/js/klatchatNano.js @@ -1,145 +1,221 @@ -let socket; - -const sioTriggeringEvents = ['configLoaded', 'configNanoLoaded']; - -sioTriggeringEvents.forEach(event => { - document.addEventListener(event, _ => { - socket = initSIO(); - }); -}); - /** - * Inits socket io client listener by attaching relevant listeners on message channels - * @return {Socket} Socket IO client instance + * Downloads desired content + * @param content: content to download + * @param filename: name of the file to download + * @param contentType: type of the content */ -function initSIO() { - - const sioServerURL = configData['CHAT_SERVER_URL_BASE']; +function download(content, filename, contentType = 'application/octet-stream') { + if (content) { + const a = document.createElement('a'); + const blob = new Blob([content], { + 'type': contentType + }); + a.href = window.URL.createObjectURL(blob); + a.target = 'blank'; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(content); + } else { + console.warn('Skipping downloading as content is invalid') + } +} - const socket = io( - sioServerURL, { - extraHeaders: { - "session": getSessionToken() - } +/** + * Handles error while loading the image data + * @param image: target image Node + */ +function handleImgError(image) { + image.parentElement.insertAdjacentHTML('afterbegin', `

${image.getAttribute('alt')}

`); + image.parentElement.removeChild(image); +} +/** + * Resolves user reply on message + * @param replyID: id of user reply + * @param repliedID id of replied message + */ +function resolveUserReply(replyID, repliedID) { + if (repliedID) { + const repliedElem = document.getElementById(repliedID); + if (repliedElem) { + let repliedText = repliedElem.getElementsByClassName('message-text')[0].innerText; + repliedText = shrinkToFit(repliedText, 15); + const replyHTML = ` +${repliedText} +`; + const replyPlaceholder = document.getElementById(replyID).getElementsByClassName('reply-placeholder')[0]; + replyPlaceholder.insertAdjacentHTML('afterbegin', replyHTML); + attachReplyHighlighting(replyPlaceholder.getElementsByClassName('reply-text')[0]); } - ); - - socket.__proto__.emitAuthorized = (event, data) => { - socket.io.opts.extraHeaders.session = getSessionToken(); - return socket.emit(event, data); } +} - socket.on('auth_expired', () => { - if (currentUser && Object.keys(currentUser).length > 0) { - console.log('Authorization Token expired, refreshing...') - location.reload(); - } +/** + * Attaches reply highlighting for reply item + * @param replyItem reply item element + */ +function attachReplyHighlighting(replyItem) { + replyItem.addEventListener('click', (e) => { + const repliedItem = document.getElementById(replyItem.getAttribute('data-replied-id')); + const backgroundParent = repliedItem.parentElement.parentElement; + repliedItem.scrollIntoView(); + backgroundParent.classList.remove('message-selected'); + setTimeout(() => backgroundParent.classList.add('message-selected'), 500); }); +} - socket.on('connect', () => { - console.info(`Socket IO Connected to Server: ${sioServerURL}`) - }); +/** + * Attaches message replies to initialized conversation + * @param conversationData: conversation data object + */ +function attachReplies(conversationData) { + if (conversationData.hasOwnProperty('chat_flow')) { + getUserMessages(conversationData).forEach(message => { + resolveUserReply(message['message_id'], message?.replied_message); + }); + Array.from(document.getElementsByClassName('reply-text')).forEach(replyItem => { + attachReplyHighlighting(replyItem); + }); + } +} +let userSettingsModal; +let applyUserSettings; +let minifyMessagesCheck; +let settingsLink; - socket.on("connect_error", (err) => { - console.log(`connect_error due to ${err.message}`); +/** + * Displays relevant user settings section based on provided name + * @param name: name of the section to display + */ +const displaySection = (name) => { + Array.from(document.getElementsByClassName('user-settings-section')).forEach(elem => { + elem.hidden = true; }); + const elem = document.getElementById(`user-settings-${name}-section`); + elem.hidden = false; +} - socket.on('new_prompt_created', async (prompt) => { - const messageContainer = getMessageListContainer(prompt['cid']); - const promptID = prompt['_id']; - if (await getCurrentSkin(prompt['cid']) === CONVERSATION_SKINS.PROMPTS) { - if (!document.getElementById(promptID)) { - const messageHTML = await buildPromptHTML(prompt); - messageContainer.insertAdjacentHTML('beforeend', messageHTML); - } - } - }); +/** + * Displays user settings based on received preferences + * @param preferences + */ +const displayUserSettings = (preferences) => { + if (preferences) { + minifyMessagesCheck.checked = preferences?.minify_messages === '1' + } +} - socket.on('new_message', async (data) => { - if (await getCurrentSkin(data.cid) === CONVERSATION_SKINS.PROMPTS && data?.prompt_id) { - console.debug('Skipping prompt-related message') - return - } - // console.debug('received new_message -> ', data) - const preferredLang = getPreferredLanguage(data['cid']); - if (data?.lang !== preferredLang) { - requestTranslation(data['cid'], data['messageID']).catch(err => console.error(`Failed to request translation of cid=${data['cid']} messageID=${data['messageID']}: ${err}`)); - } - addNewMessage(data['cid'], data['userID'], data['messageID'], data['messageText'], data['timeCreated'], data['repliedMessage'], data['attachments'], data?.isAudio, data?.isAnnouncement) - .then(_ => addMessageTransformCallback(data['cid'], data['messageID'], data?.isAudio)) - .catch(err => console.error('Error occurred while adding new message: ', err)); - }); +/** + * Initialises section of settings based on provided name + * @param sectionName: name of the section provided + */ +const initSettingsSection = async (sectionName) => { + await refreshCurrentUser(false) + .then(userData => displayUserSettings(userData?.preferences)) + .then(_ => displaySection(sectionName)); +} - socket.on('new_prompt_message', async (message) => { - await addPromptMessage(message['cid'], message['userID'], message['messageText'], message['promptID'], message['promptState']) - .catch(err => console.error('Error occurred while adding new prompt data: ', err)); +/** + * Initialises User Settings Modal + */ +const initSettingsModal = async () => { + Array.from(document.getElementsByClassName('nav-user-settings')).forEach(navItem => { + navItem.addEventListener('click', async (e) => { + await initSettingsSection(navItem.getAttribute('data-section-name')); + }); }); +} - socket.on('set_prompt_completed', async (data) => { - const promptID = data['prompt_id']; - const promptElem = document.getElementById(promptID); - console.info(`setting prompt_id=${promptID} as completed`); - if (promptElem) { - const promptWinner = document.getElementById(`${promptID}_winner`); - promptWinner.innerHTML = getPromptWinnerText(data['winner']); +/** + * Applies new settings to current user + */ +const applyNewSettings = async () => { + const newUserSettings = { + 'minify_messages': minifyMessagesCheck.checked ? '1' : '0' + }; + const query_url = 'preferences/update' + await fetchServer(query_url, REQUEST_METHODS.POST, newUserSettings, true).then(async response => { + const responseJson = await response.json(); + if (response.ok) { + location.reload(); } else { - console.warn(`Failed to get HTML element from prompt_id=${promptID}`); + displayAlert(document.getElementById(`userSettingsModalBody`), + `${responseJson['msg']}`, + 'danger'); } }); +} - socket.on('translation_response', async (data) => { - console.debug('translation_response: ', data) - await applyTranslations(data); - }); - - socket.on('subminds_state', async (data) => { - console.debug('subminds_state: ', data) - parseSubmindsState(data); - }); - - socket.on('incoming_tts', (data) => { - console.debug('received incoming stt audio'); - playTTS(data['cid'], data['lang'], data['audio_data']); - }); - - socket.on('incoming_stt', (data) => { - console.debug('received incoming stt response'); - showSTT(data['message_id'], data['lang'], data['message_text']); +function initSettings(elem) { + elem.addEventListener('click', async (e) => { + await initSettingsModal(); + userSettingsModal.modal('show'); }); - - // socket.on('updated_shouts', async (data) =>{ - // const inputType = data['input_type']; - // for (const [cid, shouts] of Object.entries(data['translations'])){ - // if (await getCurrentSkin(cid) === CONVERSATION_SKINS.BASE){ - // await requestTranslation(cid, shouts, null, inputType); - // } - // } - // }); - - return socket; } -const myAccountLink = document.getElementById('myAccountLink'); /** - * Shows modal associated with profile - * @param nick: nickname to fetch - * @param edit: to open modal in edit mode - * - * @return true if modal shown successfully, false otherwise + * Initialise user settings links based on the current client */ -async function showProfileModal(userID = null, edit = '0') { - let fetchURL = `${configData['currentURLBase']}/components/profile?` - let modalId; - let avatarId; - if (edit === '1') { - modalId = `${currentUser['_id']}EditModal`; - // avatarId = `${currentUser['nickname']}EditAvatar`; - fetchURL += `edit=1`; +const initSettingsLinks = () => { + if (configData.client === CLIENTS.NANO) { + console.log('initialising settings link for ', Array.from(document.getElementsByClassName('settings-link')).length, ' elements') + Array.from(document.getElementsByClassName('settings-link')).forEach(elem => { + initSettings(elem); + }); } else { - modalId = `${userID}Modal`; - // avatarId = `${nick}Avatar`; - fetchURL += `user_id=${userID}`; + initSettings(document.getElementById('settingsLink')); + } +} + +document.addEventListener('DOMContentLoaded', (_) => { + if (configData.client === CLIENTS.MAIN) { + userSettingsModal = $('#userSettingsModal'); + applyUserSettings = document.getElementById('applyUserSettings'); + minifyMessagesCheck = document.getElementById('minifyMessages'); + applyUserSettings.addEventListener('click', async (e) => await applyNewSettings()); + settingsLink = document.getElementById('settingsLink'); + settingsLink.addEventListener('click', async (e) => { + e.preventDefault(); + await initSettingsModal(); + userSettingsModal.modal('show'); + }); + } else { + document.addEventListener('modalsLoaded', (e) => { + userSettingsModal = $('#userSettingsModal'); + applyUserSettings = document.getElementById('applyUserSettings'); + minifyMessagesCheck = document.getElementById('minifyMessages'); + applyUserSettings.addEventListener('click', async (e) => await applyNewSettings()); + if (configData.client === CLIENTS.MAIN) { + initSettingsLinks(); + } + }); + + document.addEventListener('nanoChatsLoaded', (e) => { + setTimeout(() => initSettingsLinks(), 1000); + }) + } +}); +const myAccountLink = document.getElementById('myAccountLink'); + +/** + * Shows modal associated with profile + * @param nick: nickname to fetch + * @param edit: to open modal in edit mode + * + * @return true if modal shown successfully, false otherwise + */ +async function showProfileModal(userID = null, edit = '0') { + let fetchURL = `${configData['currentURLBase']}/components/profile?` + let modalId; + let avatarId; + if (edit === '1') { + modalId = `${currentUser['_id']}EditModal`; + // avatarId = `${currentUser['nickname']}EditAvatar`; + fetchURL += `edit=1`; + } else { + modalId = `${userID}Modal`; + // avatarId = `${nick}Avatar`; + fetchURL += `user_id=${userID}`; } const profileModalHTML = await fetch(fetchURL, { headers: new Headers({ @@ -275,459 +351,681 @@ document.addEventListener('DOMContentLoaded', (e) => { attachEditModalInvoker(myAccountLink); } }); -/** - * Returns DOM container for message elements under specific conversation id - * @param cid: conversation id to consider - * @return {Element} DOM container for message elements of considered conversation - */ -const getMessageListContainer = (cid) => { - const cidElem = document.getElementById(cid); - if (cidElem) { - return cidElem.getElementsByClassName('card-body')[0].getElementsByClassName('chat-list')[0] - } +let currentUserNavDisplay = document.getElementById('currentUserNavDisplay'); +/* Login items */ +let loginModal; +let loginButton; +let loginUsername; +let loginPassword; +let toggleSignup; +/* Logout Items */ +let logoutModal; +let logoutConfirm; +/* Signup items */ +let signupModal; +let signupButton; +let signupUsername; +let signupFirstName; +let signupLastName; +let signupPassword; +let repeatSignupPassword; +let toggleLogin; + +let currentUser = null; + + +function initModalElements() { + currentUserNavDisplay = document.getElementById('currentUserNavDisplay'); + logoutModal = $('#logoutModal'); + logoutConfirm = document.getElementById('logoutConfirm'); + loginModal = $('#loginModal'); + loginButton = document.getElementById('loginButton'); + loginUsername = document.getElementById('loginUsername'); + loginPassword = document.getElementById('loginPassword'); + toggleSignup = document.getElementById('toggleSignup'); + signupModal = $('#signupModal'); + signupButton = document.getElementById('signupButton'); + signupUsername = document.getElementById('signupUsername'); + signupFirstName = document.getElementById('signupFirstName'); + signupLastName = document.getElementById('signupLastName'); + signupPassword = document.getElementById('signupPassword'); + repeatSignupPassword = document.getElementById('repeatSignupPassword'); + toggleLogin = document.getElementById('toggleLogin'); +} + + +const MODAL_NAMES = { + LOGIN: 'login', + LOGOUT: 'logout', + SIGN_UP: 'signup', + USER_SETTINGS: 'user_settings' } + /** - * Gets message node from the message container - * @param messageContainer: DOM Message Container element to consider - * @param validateType: type of message to validate - * @return {HTMLElement} ID of the message + * Adds new modal under specific conversation id + * @param name: name of the modal from MODAL_NAMES to add */ -const getMessageNode = (messageContainer, validateType = null) => { - let detectedType; - let node - if (messageContainer.getElementsByTagName('table').length > 0) { - detectedType = 'prompt'; - node = messageContainer.getElementsByTagName('table')[0]; - } else { - detectedType = 'plain' - node = messageContainer.getElementsByClassName('chat-body')[0].getElementsByClassName('chat-message')[0]; - } - if (validateType && validateType !== detectedType) { - return null; +async function addModal(name) { + if (Object.values(MODAL_NAMES).includes(name)) { + return await buildHTMLFromTemplate(`modals.${name}`) } else { - return node; + console.warn(`Unresolved modal name - ${name}`) } } /** - * Adds new message to desired conversation id - * @param cid: desired conversation id - * @param userID: message sender id - * @param messageID: id of sent message (gets generated if null) - * @param messageText: text of the message - * @param timeCreated: timestamp for message creation - * @param repliedMessageID: id of the replied message (optional) - * @param attachments: array of attachments to add (optional) - * @param isAudio: is audio message (defaults to '0') - * @param isAnnouncement: is message an announcement (defaults to "0") - * @returns {Promise}: promise resolving id of added message, -1 if failed to resolve message id creation + * Initializes modals per target conversation id (if not provided - for main client) + * @param parentID: id of the parent to attach element to */ -async function addNewMessage(cid, userID = null, messageID = null, messageText, timeCreated, repliedMessageID = null, attachments = [], isAudio = '0', isAnnouncement = '0') { - const messageList = getMessageListContainer(cid); - if (messageList) { - let userData; - const isMine = userID === currentUser['_id']; - if (isMine) { - userData = currentUser; - } else { - userData = await getUserData(userID); - } - if (!messageID) { - messageID = generateUUID(); +async function initModals(parentID = null) { + if (parentID) { + const parentElem = document.getElementById(parentID); + if (!parentElem) { + console.warn('No element detected with provided parentID=', parentID) + return -1; } - let messageHTML = await buildUserMessageHTML(userData, cid, messageID, messageText, timeCreated, isMine, isAudio, isAnnouncement); - const blankChat = messageList.getElementsByClassName('blank_chat'); - if (blankChat.length > 0) { - messageList.removeChild(blankChat[0]); + for (const modalName of [ + MODAL_NAMES.LOGIN, + MODAL_NAMES.LOGOUT, + MODAL_NAMES.SIGN_UP, + MODAL_NAMES.USER_SETTINGS + ]) { + const modalHTML = await addModal(modalName); + parentElem.insertAdjacentHTML('beforeend', modalHTML); } - messageList.insertAdjacentHTML('beforeend', messageHTML); - resolveMessageAttachments(cid, messageID, attachments); - resolveUserReply(messageID, repliedMessageID); - addProfileDisplay(userID, cid, messageID, 'plain'); - scrollOnNewMessage(messageList); - return messageID; } + initModalElements(); + logoutConfirm.addEventListener('click', (e) => { + e.preventDefault(); + logoutUser().catch(err => console.error('Error while logging out user: ', err)); + }); + toggleLogin.addEventListener('click', (e) => { + e.preventDefault(); + signupModal.modal('hide'); + loginModal.modal('show'); + }); + loginButton.addEventListener('click', (e) => { + e.preventDefault(); + loginUser().catch(err => console.error('Error while logging in user: ', err)); + }); + toggleSignup.addEventListener('click', (e) => { + e.preventDefault(); + loginModal.modal('hide'); + signupModal.modal('show'); + }); + signupButton.addEventListener('click', (e) => { + e.preventDefault(); + createUser().catch(err => console.error('Error while creating a user: ', err)); + }); + const modalsLoaded = new CustomEvent('modalsLoaded'); + document.dispatchEvent(modalsLoaded); } -const PROMPT_STATES = { - 1: 'RESP', - 2: 'DISC', - 3: 'VOTE' -} -/** - * Returns HTML Element representing user row in prompt - * @param promptID: target prompt id - * @param userID: target user id - * @return {HTMLElement}: HTML Element containing user prompt data - */ -const getUserPromptTR = (promptID, userID) => { - return document.getElementById(`${promptID}_${userID}_prompt_row`); -} +const USER_DATA_CACHE = {} +const USER_DATA_CACHE_EXPIRY_SECONDS = 3600; /** - * Adds prompt message of specified user id - * @param cid: target conversation id - * @param userID: target submind user id - * @param messageText: message of submind - * @param promptId: target prompt id - * @param promptState: prompt state to consider + * Gets user data from local cache + * @param userID - id of the user to look-up (lookups authorized user if null) + * @returns {Promise<{}>} promise resolving obtaining of user data */ -async function addPromptMessage(cid, userID, messageText, promptId, promptState) { - const tableBody = document.getElementById(`${promptId}_tbody`); - if (await getCurrentSkin(cid) === CONVERSATION_SKINS.PROMPTS) { - try { - promptState = PROMPT_STATES[promptState].toLowerCase(); - if (!getUserPromptTR(promptId, userID)) { - const userData = await getUserData(userID); - const newUserRow = await buildSubmindHTML(promptId, userID, userData, '', '', ''); - tableBody.insertAdjacentHTML('beforeend', newUserRow); - } - try { - const messageElem = document.getElementById(`${promptId}_${userID}_${promptState}`); - messageElem.innerText = messageText; - } catch (e) { - console.warn(`Failed to add prompt message (${cid},${userID}, ${messageText}, ${promptId}, ${promptState}) - ${e}`) - } - } catch (e) { - console.info(`Skipping message of invalid prompt state - ${promptState}`); +const getUserDataFromCache = (userID) => { + if (USER_DATA_CACHE?.[userID]?.data) { + if (getCurrentTimestamp() - USER_DATA_CACHE[userID].ts < USER_DATA_CACHE_EXPIRY_SECONDS) { + return USER_DATA_CACHE[userID].data; } } } - /** - * Returns first message id based on given element - * @param firstChild: DOM element of first message child + * Gets user data from chat client URL + * @param userID - id of the user to look-up (lookups authorized user if null) + * @returns {Promise<{}>} promise resolving obtaining of user data */ -function getFirstMessageFromCID(firstChild) { - if (firstChild.classList.contains('prompt-item')) { - const promptTable = firstChild.getElementsByTagName('table')[0]; - const promptID = promptTable.id; - const promptTBody = promptTable.getElementsByTagName('tbody')[0]; - let currentRecentMessage = null; - let currentOldestTS = null; - Array.from(promptTBody.getElementsByTagName('tr')).forEach(tr => { - const submindID = tr.getAttribute('data-submind-id'); - ['resp', 'opinion', 'vote'].forEach(phase => { - const phaseElem = document.getElementById(`${promptID}_${submindID}_${phase}`); - if (phaseElem) { - let createdOn = phaseElem.getAttribute(`data-created-on`); - const messageID = phaseElem.getAttribute(`data-message-id`) - if (createdOn && messageID) { - createdOn = parseInt(createdOn); - if (!currentOldestTS || createdOn < currentOldestTS) { - currentOldestTS = createdOn; - currentRecentMessage = messageID; - } - } - } - }); - }); - return currentRecentMessage; - } else { - return getMessageNode(firstChild, 'plain')?.id; +async function getUserData(userID = null) { + let userData = {} + let query_url = `users_api/`; + if (userID) { + const cachedUserData = getUserDataFromCache(userID); + if (cachedUserData) { + return cachedUserData; + } + query_url += '?user_id=' + userID; } + await fetchServer(query_url) + .then(response => response.ok ? response.json() : { + 'data': {} + }) + .then(data => { + userData = data['data']; + const oldToken = getSessionToken(); + if (data['token'] !== oldToken && !userID) { + setSessionToken(data['token']); + } + USER_DATA_CACHE[userID] = { + data: userData, + ts: getCurrentTimestamp() + } + }); + return userData; } /** - * Gets list of the next n-older messages - * @param cid: target conversation id - * @param skin: target conversation skin + * Method that handles fetching provided user data with valid login credentials + * @returns {Promise} promise resolving validity of user-entered data */ -async function addOldMessages(cid, skin = CONVERSATION_SKINS.BASE) { - const messageContainer = getMessageListContainer(cid); - if (messageContainer.children.length > 0) { - for (let i = 0; i < messageContainer.children.length; i++) { - const firstMessageItem = messageContainer.children[i]; - const oldestMessageTS = await DBGateway.getInstance(DB_TABLES.CHAT_MESSAGES_PAGINATION).getItem(cid).then(res => res?.oldest_created_on || null); - if (oldestMessageTS) { - const numMessages = await getCurrentSkin(cid) === CONVERSATION_SKINS.PROMPTS ? 30 : 10; - await getConversationDataByInput(cid, skin, oldestMessageTS, numMessages).then(async conversationData => { - if (messageContainer) { - const userMessageList = getUserMessages(conversationData, null); - userMessageList.sort((a, b) => { - a['created_on'] - b['created_on']; - }).reverse(); - for (const message of userMessageList) { - message['cid'] = cid; - if (!isDisplayed(getMessageID(message))) { - const messageHTML = await messageHTMLFromData(message, skin); - messageContainer.insertAdjacentHTML('afterbegin', messageHTML); - } else { - console.debug(`!!message_id=${message["message_id"]} is already displayed`) - } - } - await initMessages(conversationData, skin); - } - }).then(_ => { - firstMessageItem.scrollIntoView({ - behavior: "smooth" - }); - }); - break; - } else { - console.warn(`NONE first message id detected for cid=${cid}`) - } - } +async function loginUser() { + const loginModalBody = document.getElementById('loginModalBody'); + const query_url = `auth/login/`; + const formData = new FormData(); + const inputValues = [loginUsername.value, loginPassword.value]; + if (inputValues.includes("") || inputValues.includes(null)) { + displayAlert(loginModalBody, 'Required fields are blank', 'danger'); + } else { + formData.append('username', loginUsername.value); + formData.append('password', loginPassword.value); + await fetchServer(query_url, REQUEST_METHODS.POST, formData) + .then(async response => { + return { + 'ok': response.ok, + 'data': await response.json() + }; + }) + .then(async responseData => { + if (responseData['ok']) { + setSessionToken(responseData['data']['token']); + } else { + displayAlert(loginModalBody, responseData['data']['msg'], 'danger', 'login-failed-alert'); + loginPassword.value = ""; + } + }).catch(ex => { + console.warn(`Exception during loginUser -> ${ex}`); + displayAlert(loginModalBody); + }); } } - /** - * Returns message id based on message type - * @param message: message object to check - * @returns {null|*} message id extracted if valid message type detected + * Method that handles logging user out + * @returns {Promise} promise resolving user logout */ -const getMessageID = (message) => { - switch (message['message_type']) { - case 'plain': - return message['message_id']; - case 'prompt': - return message['_id']; - default: - console.warn(`Invalid message structure received - ${message}`); - return null; - } +async function logoutUser() { + const query_url = `auth/logout/`; + await fetchServer(query_url).then(async response => { + if (response.ok) { + const responseJson = await response.json(); + setSessionToken(responseJson['token']); + } + }); } /** - * Array of user messages in given conversation - * @param conversationData: Conversation Data object to fetch - * @param forceType: to force particular type of messages among the chat flow + * Method that handles fetching provided user data with valid sign up credentials + * @returns {Promise} promise resolving validity of new user creation */ -const getUserMessages = (conversationData, forceType = 'plain') => { - try { - let messages = Array.from(conversationData['chat_flow']); - if (forceType) { - messages = messages.filter(message => message['message_type'] === forceType); - } - return messages; - } catch { - return []; +async function createUser() { + const signupModalBody = document.getElementById('signupModalBody'); + const query_url = `auth/signup/`; + const formData = new FormData(); + const inputValues = [signupUsername.value, signupFirstName.value, signupLastName.value, signupPassword.value, repeatSignupPassword.value]; + if (inputValues.includes("") || inputValues.includes(null)) { + displayAlert(signupModalBody, 'Required fields are blank', 'danger'); + } else if (signupPassword.value !== repeatSignupPassword.value) { + displayAlert(signupModalBody, 'Passwords do not match', 'danger'); + } else { + formData.append('nickname', signupUsername.value); + formData.append('first_name', signupFirstName.value); + formData.append('last_name', signupLastName.value); + formData.append('password', signupPassword.value); + await fetchServer(query_url, REQUEST_METHODS.POST, formData) + .then(async response => { + return { + 'ok': response.ok, + 'data': await response.json() + } + }) + .then(async data => { + if (data['ok']) { + setSessionToken(data['data']['token']); + } else { + let errorMessage = 'Failed to create an account'; + if (data['data'].hasOwnProperty('msg')) { + errorMessage = data['data']['msg']; + } + displayAlert(signupModalBody, errorMessage, 'danger'); + } + }); } } /** - * Initializes listener for loading old message on scrolling conversation box - * @param conversationData: Conversation Data object to fetch - * @param skin: conversation skin to apply + * Helper method for updating navbar based on current user property + * @param forceUpdate to force updating of navbar (defaults to false) */ -function initLoadOldMessages(conversationData, skin) { - const cid = conversationData['_id']; - const messageList = getMessageListContainer(cid); - const messageListParent = messageList.parentElement; - setDefault(setDefault(conversationState, cid, {}), 'lastScrollY', 0); - messageListParent.addEventListener("scroll", async (e) => { - const oldScrollPosition = conversationState[cid]['scrollY']; - conversationState[cid]['scrollY'] = e.target.scrollTop; - if (oldScrollPosition > conversationState[cid]['scrollY'] && - !conversationState[cid]['all_messages_displayed'] && - conversationState[cid]['scrollY'] === 0) { - setChatState(cid, 'updating', 'Loading messages...') - await addOldMessages(cid, skin); - for (const inputType of ['incoming', 'outcoming']) { - await requestTranslation(cid, null, null, inputType); +function updateNavbar(forceUpdate = false) { + if (currentUser || forceUpdate) { + let innerText = shrinkToFit(currentUser['nickname'], 10); + let targetElems = [currentUserNavDisplay]; + if (configData.client === CLIENTS.MAIN) { + if (currentUser['is_tmp']) { + // Leaving only "guest" without suffix + innerText = innerText.split('_')[0] + innerText += ', Login'; + } else { + innerText += ', Logout'; } - setTimeout(() => { - setChatState(cid, 'active'); - }, 700); + } else if (configData.client === CLIENTS.NANO) { + if (currentUser['is_tmp']) { + // Leaving only "guest" without suffix + innerText = innerText.split('_')[0] + innerText += ' '; + } else { + innerText += ' '; + } + targetElems = Array.from(document.getElementsByClassName('account-link')) } - }); + if (targetElems.length > 0 && targetElems[0]) { + targetElems.forEach(elem => { + elem.innerHTML = ` +${innerText} +`; + }); + } + } } + /** - * Attaches event listener to display element's target user profile - * @param userID target user id - * @param elem target DOM element + * Refreshes HTML components appearance based on the current user + * NOTE: this must have only visual impact, the actual validation is done on the backend */ -function attachTargetProfileDisplay(userID, elem) { - if (elem) { - elem.addEventListener('click', async (_) => { - if (userID) await showProfileModal(userID) - }); +const refreshComponentsAppearance = () => { + const currentUserRoles = currentUser?.roles ?? []; + const isAdmin = currentUserRoles.includes("admin"); + + const createLiveConversationWrapper = document.getElementById("createLiveConversationWrapper"); + + if (isAdmin) { + createLiveConversationWrapper.style.display = ""; + } else { + createLiveConversationWrapper.style.display = "none"; } } /** - * Adds callback for showing profile information on profile avatar click - * @param userID target user id - * @param cid target conversation id - * @param messageId target message id - * @param messageType type of message to display + * Custom Event fired on current user loaded + * @type {CustomEvent} */ -function addProfileDisplay(userID, cid, messageId, messageType = 'plain') { - if (messageType === 'plain') { - attachTargetProfileDisplay(userID, document.getElementById(`${messageId}_avatar`)) - } else if (messageType === 'prompt') { - const promptTBody = document.getElementById(`${messageId}_tbody`); - const rows = promptTBody.getElementsByTagName('tr'); - Array.from(rows).forEach(row => { - attachTargetProfileDisplay(userID, Array.from(row.getElementsByTagName('td'))[0].getElementsByClassName('chat-img')[0]); - }) - } -} - +const currentUserLoaded = new CustomEvent("currentUserLoaded", { + "detail": "Event that is fired when current user is loaded" +}); /** - * Inits addProfileDisplay() on each message of provided conversation - * @param conversationData - target conversation data + * Convenience method encapsulating refreshing page view based on current user + * @param refreshChats: to refresh the chats (defaults to false) + * @param conversationContainer: DOM Element representing conversation container */ -function initProfileDisplay(conversationData) { - getUserMessages(conversationData, null).forEach(message => { - addProfileDisplay(message['user_id'], conversationData['_id'], getMessageID(message), message['message_type']); +async function refreshCurrentUser(refreshChats = false, conversationContainer = null) { + await getUserData().then(data => { + currentUser = data; + console.log(`Loaded current user = ${JSON.stringify(currentUser)}`); + setTimeout(() => updateNavbar(), 500); + if (refreshChats) { + refreshChatView(conversationContainer); + } + refreshComponentsAppearance() + console.log('current user loaded'); + document.dispatchEvent(currentUserLoaded); + return data; }); } + +document.addEventListener('DOMContentLoaded', async (e) => { + if (configData['client'] === CLIENTS.MAIN) { + await initModals(); + currentUserNavDisplay.addEventListener('click', (e) => { + e.preventDefault(); + currentUser['is_tmp'] ? loginModal.modal('show') : logoutModal.modal('show'); + }); + } +}); /** - * Inits pagination based on the oldest message creation timestamp - * @param conversationData - target conversation data + * Gets time object from provided UNIX timestamp + * @param timestampCreated: UNIX timestamp (in seconds) + * @returns {string} string time (hours:minutes) */ -async function initPagination(conversationData) { - const userMessages = getUserMessages(conversationData, null); - if (userMessages.length > 0) { - const oldestMessage = Math.min(...userMessages.map(msg => parseInt(msg.created_on))); - await DBGateway - .getInstance(DB_TABLES.CHAT_MESSAGES_PAGINATION) - .putItem({ - cid: conversationData['_id'], - oldest_created_on: oldestMessage - }) +function getTimeFromTimestamp(timestampCreated = 0) { + if (!timestampCreated) { + return '' } -} - + let date = new Date(timestampCreated * 1000); + let year = date.getFullYear().toString(); + let month = date.getMonth() + 1; + month = month >= 10 ? month.toString() : '0' + month.toString(); + let day = date.getDate(); -/** - * Initializes messages based on provided conversation aata - * @param conversationData - JS Object containing conversation data of type: - * { - * '_id': 'id of conversation', - * 'conversation_name': 'title of the conversation', - * 'chat_flow': [{ - * 'user_nickname': 'nickname of sender', - * 'user_avatar': 'avatar of sender', - * 'message_id': 'id of the message', - * 'message_text': 'text of the message', - * 'is_audio': true if message is an audio message - * 'is_announcement': true if message is considered to be an announcement - * 'created_on': 'creation time of the message' - * }, ... (num of user messages returned)] - * } - * @param skin - target conversation skin to consider - */ -async function initMessages(conversationData, skin) { - initProfileDisplay(conversationData); - attachReplies(conversationData); - addAttachments(conversationData); - addCommunicationChannelTransformCallback(conversationData); - initLoadOldMessages(conversationData, skin); - await initPagination(conversationData); + day = day >= 10 ? day.toString() : '0' + day.toString(); + const hours = date.getHours().toString(); + let minutes = date.getMinutes(); + minutes = minutes >= 10 ? minutes.toString() : '0' + minutes.toString(); + return strFmtDate(year, month, day, hours, minutes, null); } /** - * Emits user message to Socket IO Server - * @param textInputElem: DOM Element with input text (audio object if isAudio=true) - * @param cid: Conversation ID - * @param repliedMessageID: ID of replied message - * @param attachments: list of attachments file names - * @param isAudio: is audio message being emitted (defaults to '0') - * @param isAnnouncement: is message an announcement (defaults to '0') + * Composes date based on input params + * @param year: desired year + * @param month: desired month + * @param day: desired day + * @param hours: num of hours + * @param minutes: minutes + * @param seconds: seconds + * @return date string */ -function emitUserMessage(textInputElem, cid, repliedMessageID = null, attachments = [], isAudio = '0', isAnnouncement = '0') { - if (isAudio === '1' || textInputElem && textInputElem.value) { - const timeCreated = getCurrentTimestamp(); - let messageText; - if (isAudio === '1') { - messageText = textInputElem; - } else { - messageText = textInputElem.value; - } - addNewMessage(cid, currentUser['_id'], null, messageText, timeCreated, repliedMessageID, attachments, isAudio, isAnnouncement).then(async messageID => { - const preferredShoutLang = getPreferredLanguage(cid, 'outcoming'); - socket.emitAuthorized('user_message', { - 'cid': cid, - 'userID': currentUser['_id'], - 'messageText': messageText, - 'messageID': messageID, - 'lang': preferredShoutLang, - 'attachments': attachments, - 'isAudio': isAudio, - 'isAnnouncement': isAnnouncement, - 'timeCreated': timeCreated - }); - if (preferredShoutLang !== 'en') { - await requestTranslation(cid, messageID, 'en', 'outcoming', true); - } - addMessageTransformCallback(cid, messageID, isAudio); - }); - if (isAudio === '0') { - textInputElem.value = ""; +function strFmtDate(year, month, day, hours, minutes, seconds) { + let finalDate = ""; + if (year && month && day) { + finalDate += `${year}-${month}-${day}` + } + if (hours && minutes) { + finalDate += ` ${hours}:${minutes}` + if (seconds) { + finalDate += `:${seconds}` } } + return finalDate; } /** - * Enum of possible Alert Behaviours: - * - DEFAULT: static alert message appeared with no expiration time - * - AUTO_EXPIRE: alert message will be expired after some amount of time (defaults to 3 seconds) + * Adds speaking callback for the message + * @param cid: id of the conversation + * @param messageID: id of the message */ -const alertBehaviors = { - STATIC: 'static', - AUTO_EXPIRE: 'auto_expire' +function addTTSCallback(cid, messageID) { + const speakingButton = document.getElementById(`${messageID}_speak`); + if (speakingButton) { + speakingButton.addEventListener('click', (e) => { + e.preventDefault(); + getTTS(cid, messageID, getPreferredLanguage(cid)); + setChatState(cid, 'updating', `Fetching TTS...`) + }); + } } /** - * Adds Bootstrap alert HTML to specified element's id - * @param parentElem: DOM Element in which to display alert - * @param text: Text of alert (defaults 'Error Occurred') - * @param alertType: Type of alert from bootstrap-supported alert types (defaults to 'danger') - * @param alertID: Id of alert to display (defaults to 'alert') - * @param alertBehaviorProperties: optional properties associated with alert message behavior + * Adds speaking callback for the message + * @param cid: id of the conversation + * @param messageID: id of the message */ -function displayAlert(parentElem, text = 'Error Occurred', alertType = 'danger', alertID = 'alert', - alertBehaviorProperties = null) { - if (!parentElem) { - console.warn('Alert is not displayed as parentElem is not defined'); - return - } - if (typeof parentElem === 'string') { - parentElem = document.getElementById(parentElem); - } - if (!['info', 'success', 'warning', 'danger', 'primary', 'secondary', 'dark'].includes(alertType)) { - alertType = 'danger'; //default - } - let alert = document.getElementById(alertID); - if (alert) { - alert.remove(); - } - - if (!alertBehaviorProperties) { - alertBehaviorProperties = { - 'type': alertBehaviors.AUTO_EXPIRE, - } - } - - if (text) { - parentElem.insertAdjacentHTML('afterbegin', - ``); - if (alertBehaviorProperties) { - setDefault(alertBehaviorProperties, 'type', alertBehaviors.STATIC); - if (alertBehaviorProperties['type'] === alertBehaviors.AUTO_EXPIRE) { - const expirationTime = setDefault(alertBehaviorProperties, 'expiration', 3000); - const slideLength = setDefault(alertBehaviorProperties, 'fadeLength', 500); - setTimeout(function() { - $(`#${alertID}`).slideUp(slideLength, () => { - $(this).remove(); - }); - }, expirationTime); +function addSTTCallback(cid, messageID) { + const sttButton = document.getElementById(`${messageID}_text`); + if (sttButton) { + sttButton.addEventListener('click', (e) => { + e.preventDefault(); + const sttContent = document.getElementById(`${messageID}-stt`); + if (sttContent) { + sttContent.innerHTML = `
+Waiting for STT...
+Loading... +
+
`; + sttContent.style.setProperty('display', 'block', 'important'); + getSTT(cid, messageID, getPreferredLanguage(cid)); } - } + }); + } +} + +/** + * Attaches STT capabilities for audio messages and TTS capabilities for text messages + * @param cid: parent conversation id + * @param messageID: target message id + * @param isAudio: if its an audio message (defaults to '0') + */ +function addMessageTransformCallback(cid, messageID, isAudio = '0') { + if (isAudio === '1') { + addSTTCallback(cid, messageID); + } else { + addTTSCallback(cid, messageID); + } +} + + +/** + * Attaches STT capabilities for audio messages and TTS capabilities for text messages + * @param conversationData: conversation data object + */ +function addCommunicationChannelTransformCallback(conversationData) { + if (conversationData.hasOwnProperty('chat_flow')) { + getUserMessages(conversationData).forEach(message => { + addMessageTransformCallback(conversationData['_id'], message['message_id'], message?.is_audio); + }); + } +} +/** + * Collection of supported clients, current client is matched based on client configuration + * @type {{NANO: string, MAIN: string}} + */ +const CLIENTS = { + MAIN: 'main', + NANO: 'nano', + UNDEFINED: undefined +} + +/** + * JS Object containing frontend configuration data + * @type {{staticFolder: string, currentURLBase: string, currentURLFull: (string|string|string|SVGAnimatedString|*), client: string}} + */ + +let configData = { + 'staticFolder': "../../static", + 'currentURLBase': extractURLBase(), + 'currentURLFull': window.location.href, + 'client': typeof metaConfig !== 'undefined' ? metaConfig?.client : CLIENTS.UNDEFINED, + "MAX_CONVERSATIONS_PER_PAGE": 4, +}; + +/** + * Default key for storing data in local storage + * @type {string} + */ +const conversationAlignmentKey = 'conversationAlignment'; + +/** + * Custom Event fired on configs ended up loading + * @type {CustomEvent} + */ +const configFullLoadedEvent = new CustomEvent("configLoaded", { + "detail": "Event that is fired when configs are loaded" +}); + +/** + * Convenience method for getting URL base for current page + * @returns {string} constructed URL base + */ +function extractURLBase() { + return window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : ''); +} + +/** + * Extracts json data from provided URL path + * @param urlPath - file path string + * @param onError - callback on extraction failure + * @returns {Promise<* | {}>} promise that resolves data obtained from file path + */ +async function extractJsonData(urlPath = "", + onError = (e) => console.error(`failed to extractJsonData - ${e}`)) { + return fetch(urlPath).then(response => { + if (response.ok) { + return response.json(); + } + return {}; + }).catch(onError); +} + + +document.addEventListener('DOMContentLoaded', async (e) => { + if (configData['client'] === CLIENTS.MAIN) { + configData = Object.assign(configData, await extractJsonData(`${configData['currentURLBase']}/base/runtime_config`), (e) => location.reload()); + document.dispatchEvent(configFullLoadedEvent); + } +}); +/** + * Returns current UNIX timestamp in seconds + * @return {number}: current unix timestamp + */ +const getCurrentTimestamp = () => { + return Math.floor(Date.now() / 1000); +}; + +// Client's timer +// TODO consider refactoring to "timer per component" if needed +let __timer = 0; + + +/** + * Sets timer to current timestamp + */ +const startTimer = () => { + __timer = Date.now(); +}; + +/** + * Resets times and returns time elapsed since invocation of startTimer() + * @return {number} Number of seconds elapsed + */ +const stopTimer = () => { + const timeDue = Date.now() - __timer; + __timer = 0; + return timeDue; +}; +const REQUEST_METHODS = { + GET: 'GET', + PUT: 'PUT', + DELETE: 'DELETE', + POST: 'POST' +} + +const controllers = new Set(); + + +const getSessionToken = () => { + return localStorage.getItem('session') || ''; +} + +const setSessionToken = (val) => { + const currentValue = getSessionToken(); + localStorage.setItem('session', val); + if (currentValue && currentValue !== val) { + location.reload(); + } +} + +const fetchServer = async (urlSuffix, method = REQUEST_METHODS.GET, body = null, json = false) => { + const controller = new AbortController(); + controllers.add(controller); + const signal = controller.signal; + + const options = { + method: method, + headers: new Headers({ + 'Authorization': getSessionToken() + }), + signal, + } + if (body) { + options['body'] = body; + } + // TODO: there is an issue validating FormData on backend, so JSON property should eventually become true + if (json) { + options['headers'].append('Content-Type', 'application/json'); + if (options['body']) { + options['body'] &&= JSON.stringify(options['body']) + } + } + return fetch(`${configData["CHAT_SERVER_URL_BASE"]}/${urlSuffix}`, options).then(async response => { + if (response.status === 401) { + const responseJson = await response.json(); + if (responseJson['msg'] === 'Session token is invalid or expired') { + localStorage.removeItem('session'); + location.reload(); + } + } + return response; + }).finally(() => { + controllers.delete(controller); + }); +} + + +document.addEventListener('beforeunload', () => { + for (const controller of controllers) { + controller.abort(); + } +}); +/** + * Enum of possible Alert Behaviours: + * - DEFAULT: static alert message appeared with no expiration time + * - AUTO_EXPIRE: alert message will be expired after some amount of time (defaults to 3 seconds) + */ +const alertBehaviors = { + STATIC: 'static', + AUTO_EXPIRE: 'auto_expire' +} + +/** + * Adds Bootstrap alert HTML to specified element's id + * @param parentElem: DOM Element in which to display alert + * @param text: Text of alert (defaults 'Error Occurred') + * @param alertType: Type of alert from bootstrap-supported alert types (defaults to 'danger') + * @param alertID: Id of alert to display (defaults to 'alert') + * @param alertBehaviorProperties: optional properties associated with alert message behavior + */ +function displayAlert(parentElem, text = 'Error Occurred', alertType = 'danger', alertID = 'alert', + alertBehaviorProperties = null) { + if (!parentElem) { + console.warn('Alert is not displayed as parentElem is not defined'); + return + } + if (typeof parentElem === 'string') { + parentElem = document.getElementById(parentElem); + } + if (!['info', 'success', 'warning', 'danger', 'primary', 'secondary', 'dark'].includes(alertType)) { + alertType = 'danger'; //default + } + let alert = document.getElementById(alertID); + if (alert) { + alert.remove(); + } + + if (!alertBehaviorProperties) { + alertBehaviorProperties = { + 'type': alertBehaviors.AUTO_EXPIRE, + } + } + + if (text) { + parentElem.insertAdjacentHTML('afterbegin', + ``); + if (alertBehaviorProperties) { + setDefault(alertBehaviorProperties, 'type', alertBehaviors.STATIC); + if (alertBehaviorProperties['type'] === alertBehaviors.AUTO_EXPIRE) { + const expirationTime = setDefault(alertBehaviorProperties, 'expiration', 3000); + const slideLength = setDefault(alertBehaviorProperties, 'fadeLength', 500); + setTimeout(function() { + $(`#${alertID}`).slideUp(slideLength, () => { + $(this).remove(); + }); + }, expirationTime); + } + } } } @@ -883,788 +1181,397 @@ const MIMES = [ ]; const IMAGE_EXTENSIONS = MIMES.filter(item => item[1].startsWith('image/')).map(item => item[0]); -let submindsState; - -function renderActiveSubminds(cid) { - if (!submindsState) { - console.log(`Subminds for CID ${cid} not yet loaded.`); - return; - } - const loadingSpinner = document.getElementById(`${cid}-subminds-state-loading`); - if (loadingSpinner) { - loadingSpinner.classList.remove('d-flex'); - loadingSpinner.style.display = 'none'; - } - - const dropdownMenu = document.getElementById(`bot-list-${cid}`); - dropdownMenu.addEventListener('click', (event) => { - event.stopPropagation(); - }); - - const table = document.getElementById(`${cid}-subminds-state-table`); - const entriesContainer = document.getElementById(`${cid}-subminds-state-entries`); - const buttonsContainer = document.getElementById(`${cid}-subminds-buttons`); - buttonsContainer.style.display = 'none'; - const cancelButton = document.getElementById(`${cid}-reset-button`); - const submitButton = document.getElementById(`${cid}-submit-button`); - - const { - subminds_per_cid: submindsPerCID, - connected_subminds: connectedSubminds - } = submindsState; - - const activeSubminds = submindsPerCID?.[cid]?.filter(submind => submind.status === 'active') || []; - const activeSubmindServices = new Set(activeSubminds.map(submind => submind.submind_id.slice(0, submind.submind_id.lastIndexOf('-')))) - - const banned_subminds = submindsPerCID?.[cid]?.filter(submind => submind.status === 'banned') || []; - const bannedSubmindIds = new Set(banned_subminds.map(submind => submind.submind_id)); - - const initialSubmindsState = []; - const processedServiceNames = []; - for (let [submindID, submindData] of Object.entries(connectedSubminds || {})) { - const serviceName = submindData.service_name; - const botType = submindData.bot_type; - if (botType === "submind" && !bannedSubmindIds.has(submindID) && !processedServiceNames.includes(serviceName)) { - processedServiceNames.push(serviceName) - initialSubmindsState.push({ - service_name: serviceName, - is_active: activeSubmindServices.has(serviceName) - }) - } - } - initialSubmindsState.sort((a, b) => { - return b.is_active - a.is_active; - }) - - let currentState = structuredClone(initialSubmindsState); - - const updateButtonVisibility = () => { - const hasChanges = initialSubmindsState.some((submind, index) => submind.is_active !== currentState[index].is_active); - buttonsContainer.style.display = hasChanges ? 'block' : 'none'; - }; - - table.style.display = ''; - entriesContainer.innerHTML = ''; - - initialSubmindsState.forEach((submind, index) => { - const row = document.createElement('tr'); - row.innerHTML = ` -${submind.service_name} - -
- - -
- -`; - - const checkbox = row.querySelector(`#toggle-${cid}-${submind.service_name}`); - checkbox.addEventListener('change', () => { - currentState[index].is_active = checkbox.checked; - updateButtonVisibility(); - }); - entriesContainer.appendChild(row); - }); - - cancelButton.onclick = () => { - currentState = structuredClone(initialSubmindsState); - currentState.forEach((submind, index) => { - const checkbox = document.getElementById(`toggle-${cid}-${submind.service_name}`); - checkbox.checked = (submind.is_active) ? "checked" : ''; - }); - updateButtonVisibility(); - }; - - submitButton.onclick = () => { - const modifiedSubminds = currentState.filter((current, index) => { - return current.is_active !== initialSubmindsState[index].is_active; - }); - - let subminds_to_remove = modifiedSubminds.filter(submind => !submind.is_active).map(submind => submind.service_name); - let subminds_to_add = modifiedSubminds.filter(submind => submind.is_active).map(submind => submind.service_name); - - if (subminds_to_add.length !== 0 || subminds_to_remove.length !== 0) { - socket.emit('broadcast', { - msg_type: "update_participating_subminds", - "cid": cid, - "subminds_to_invite": subminds_to_add, - "subminds_to_kick": subminds_to_remove, - }); - } - - const dropdownToggle = document.getElementById(`dropdownToggle-${cid}`); - if (dropdownToggle) dropdownToggle.click(); - - buttonsContainer.style.display = 'none'; - }; -} - - -function parseSubmindsState(data) { - submindsState = data; - - const cids = Object.keys(submindsState["subminds_per_cid"]) - if (cids.length === 0) { - setAllCountersToZero(); - } else { - for (const cid of cids) { - refreshSubmindsCount(cid); - } - } -} -let currentUserNavDisplay = document.getElementById('currentUserNavDisplay'); -/* Login items */ -let loginModal; -let loginButton; -let loginUsername; -let loginPassword; -let toggleSignup; -/* Logout Items */ -let logoutModal; -let logoutConfirm; -/* Signup items */ -let signupModal; -let signupButton; -let signupUsername; -let signupFirstName; -let signupLastName; -let signupPassword; -let repeatSignupPassword; -let toggleLogin; - -let currentUser = null; - - -function initModalElements() { - currentUserNavDisplay = document.getElementById('currentUserNavDisplay'); - logoutModal = $('#logoutModal'); - logoutConfirm = document.getElementById('logoutConfirm'); - loginModal = $('#loginModal'); - loginButton = document.getElementById('loginButton'); - loginUsername = document.getElementById('loginUsername'); - loginPassword = document.getElementById('loginPassword'); - toggleSignup = document.getElementById('toggleSignup'); - signupModal = $('#signupModal'); - signupButton = document.getElementById('signupButton'); - signupUsername = document.getElementById('signupUsername'); - signupFirstName = document.getElementById('signupFirstName'); - signupLastName = document.getElementById('signupLastName'); - signupPassword = document.getElementById('signupPassword'); - repeatSignupPassword = document.getElementById('repeatSignupPassword'); - toggleLogin = document.getElementById('toggleLogin'); -} - - -const MODAL_NAMES = { - LOGIN: 'login', - LOGOUT: 'logout', - SIGN_UP: 'signup', - USER_SETTINGS: 'user_settings' -} - - -/** - * Adds new modal under specific conversation id - * @param name: name of the modal from MODAL_NAMES to add - */ -async function addModal(name) { - if (Object.values(MODAL_NAMES).includes(name)) { - return await buildHTMLFromTemplate(`modals.${name}`) - } else { - console.warn(`Unresolved modal name - ${name}`) - } -} - -/** - * Initializes modals per target conversation id (if not provided - for main client) - * @param parentID: id of the parent to attach element to - */ -async function initModals(parentID = null) { - if (parentID) { - const parentElem = document.getElementById(parentID); - if (!parentElem) { - console.warn('No element detected with provided parentID=', parentID) - return -1; - } - for (const modalName of [ - MODAL_NAMES.LOGIN, - MODAL_NAMES.LOGOUT, - MODAL_NAMES.SIGN_UP, - MODAL_NAMES.USER_SETTINGS - ]) { - const modalHTML = await addModal(modalName); - parentElem.insertAdjacentHTML('beforeend', modalHTML); - } - } - initModalElements(); - logoutConfirm.addEventListener('click', (e) => { - e.preventDefault(); - logoutUser().catch(err => console.error('Error while logging out user: ', err)); - }); - toggleLogin.addEventListener('click', (e) => { - e.preventDefault(); - signupModal.modal('hide'); - loginModal.modal('show'); - }); - loginButton.addEventListener('click', (e) => { - e.preventDefault(); - loginUser().catch(err => console.error('Error while logging in user: ', err)); - }); - toggleSignup.addEventListener('click', (e) => { - e.preventDefault(); - loginModal.modal('hide'); - signupModal.modal('show'); - }); - signupButton.addEventListener('click', (e) => { - e.preventDefault(); - createUser().catch(err => console.error('Error while creating a user: ', err)); - }); - const modalsLoaded = new CustomEvent('modalsLoaded'); - document.dispatchEvent(modalsLoaded); -} - - -const USER_DATA_CACHE = {} -const USER_DATA_CACHE_EXPIRY_SECONDS = 3600; - -/** - * Gets user data from local cache - * @param userID - id of the user to look-up (lookups authorized user if null) - * @returns {Promise<{}>} promise resolving obtaining of user data - */ -const getUserDataFromCache = (userID) => { - if (USER_DATA_CACHE?.[userID]?.data) { - if (getCurrentTimestamp() - USER_DATA_CACHE[userID].ts < USER_DATA_CACHE_EXPIRY_SECONDS) { - return USER_DATA_CACHE[userID].data; - } - } -} - -/** - * Gets user data from chat client URL - * @param userID - id of the user to look-up (lookups authorized user if null) - * @returns {Promise<{}>} promise resolving obtaining of user data - */ -async function getUserData(userID = null) { - let userData = {} - let query_url = `users_api/`; - if (userID) { - const cachedUserData = getUserDataFromCache(userID); - if (cachedUserData) { - return cachedUserData; - } - query_url += '?user_id=' + userID; - } - await fetchServer(query_url) - .then(response => response.ok ? response.json() : { - 'data': {} - }) - .then(data => { - userData = data['data']; - const oldToken = getSessionToken(); - if (data['token'] !== oldToken && !userID) { - setSessionToken(data['token']); - } - USER_DATA_CACHE[userID] = { - data: userData, - ts: getCurrentTimestamp() - } - }); - return userData; -} - -/** - * Method that handles fetching provided user data with valid login credentials - * @returns {Promise} promise resolving validity of user-entered data - */ -async function loginUser() { - const loginModalBody = document.getElementById('loginModalBody'); - const query_url = `auth/login/`; - const formData = new FormData(); - const inputValues = [loginUsername.value, loginPassword.value]; - if (inputValues.includes("") || inputValues.includes(null)) { - displayAlert(loginModalBody, 'Required fields are blank', 'danger'); - } else { - formData.append('username', loginUsername.value); - formData.append('password', loginPassword.value); - await fetchServer(query_url, REQUEST_METHODS.POST, formData) - .then(async response => { - return { - 'ok': response.ok, - 'data': await response.json() - }; - }) - .then(async responseData => { - if (responseData['ok']) { - setSessionToken(responseData['data']['token']); - } else { - displayAlert(loginModalBody, responseData['data']['msg'], 'danger', 'login-failed-alert'); - loginPassword.value = ""; - } - }).catch(ex => { - console.warn(`Exception during loginUser -> ${ex}`); - displayAlert(loginModalBody); - }); - } -} - -/** - * Method that handles logging user out - * @returns {Promise} promise resolving user logout - */ -async function logoutUser() { - const query_url = `auth/logout/`; - await fetchServer(query_url).then(async response => { - if (response.ok) { - const responseJson = await response.json(); - setSessionToken(responseJson['token']); - } - }); -} - -/** - * Method that handles fetching provided user data with valid sign up credentials - * @returns {Promise} promise resolving validity of new user creation - */ -async function createUser() { - const signupModalBody = document.getElementById('signupModalBody'); - const query_url = `auth/signup/`; - const formData = new FormData(); - const inputValues = [signupUsername.value, signupFirstName.value, signupLastName.value, signupPassword.value, repeatSignupPassword.value]; - if (inputValues.includes("") || inputValues.includes(null)) { - displayAlert(signupModalBody, 'Required fields are blank', 'danger'); - } else if (signupPassword.value !== repeatSignupPassword.value) { - displayAlert(signupModalBody, 'Passwords do not match', 'danger'); - } else { - formData.append('nickname', signupUsername.value); - formData.append('first_name', signupFirstName.value); - formData.append('last_name', signupLastName.value); - formData.append('password', signupPassword.value); - await fetchServer(query_url, REQUEST_METHODS.POST, formData) - .then(async response => { - return { - 'ok': response.ok, - 'data': await response.json() - } - }) - .then(async data => { - if (data['ok']) { - setSessionToken(data['data']['token']); - } else { - let errorMessage = 'Failed to create an account'; - if (data['data'].hasOwnProperty('msg')) { - errorMessage = data['data']['msg']; - } - displayAlert(signupModalBody, errorMessage, 'danger'); - } - }); - } -} - -/** - * Helper method for updating navbar based on current user property - * @param forceUpdate to force updating of navbar (defaults to false) - */ -function updateNavbar(forceUpdate = false) { - if (currentUser || forceUpdate) { - let innerText = shrinkToFit(currentUser['nickname'], 10); - let targetElems = [currentUserNavDisplay]; - if (configData.client === CLIENTS.MAIN) { - if (currentUser['is_tmp']) { - // Leaving only "guest" without suffix - innerText = innerText.split('_')[0] - innerText += ', Login'; - } else { - innerText += ', Logout'; - } - } else if (configData.client === CLIENTS.NANO) { - if (currentUser['is_tmp']) { - // Leaving only "guest" without suffix - innerText = innerText.split('_')[0] - innerText += ' '; - } else { - innerText += ' '; - } - targetElems = Array.from(document.getElementsByClassName('account-link')) - } - if (targetElems.length > 0 && targetElems[0]) { - targetElems.forEach(elem => { - elem.innerHTML = ` -${innerText} -`; - }); - } - } -} - - /** - * Refreshes HTML components appearance based on the current user - * NOTE: this must have only visual impact, the actual validation is done on the backend - */ -const refreshComponentsAppearance = () => { - const currentUserRoles = currentUser?.roles ?? []; - const isAdmin = currentUserRoles.includes("admin"); - - const createLiveConversationWrapper = document.getElementById("createLiveConversationWrapper"); - - if (isAdmin) { - createLiveConversationWrapper.style.display = ""; - } else { - createLiveConversationWrapper.style.display = "none"; - } -} - -/** - * Custom Event fired on current user loaded - * @type {CustomEvent} - */ -const currentUserLoaded = new CustomEvent("currentUserLoaded", { - "detail": "Event that is fired when current user is loaded" -}); - -/** - * Convenience method encapsulating refreshing page view based on current user - * @param refreshChats: to refresh the chats (defaults to false) - * @param conversationContainer: DOM Element representing conversation container + * Returns DOM container for message elements under specific conversation id + * @param cid: conversation id to consider + * @return {Element} DOM container for message elements of considered conversation */ -async function refreshCurrentUser(refreshChats = false, conversationContainer = null) { - await getUserData().then(data => { - currentUser = data; - console.log(`Loaded current user = ${JSON.stringify(currentUser)}`); - setTimeout(() => updateNavbar(), 500); - if (refreshChats) { - refreshChatView(conversationContainer); - } - refreshComponentsAppearance() - console.log('current user loaded'); - document.dispatchEvent(currentUserLoaded); - return data; - }); +const getMessageListContainer = (cid) => { + const cidElem = document.getElementById(cid); + if (cidElem) { + return cidElem.getElementsByClassName('card-body')[0].getElementsByClassName('chat-list')[0] + } } - - -document.addEventListener('DOMContentLoaded', async (e) => { - if (configData['client'] === CLIENTS.MAIN) { - await initModals(); - currentUserNavDisplay.addEventListener('click', (e) => { - e.preventDefault(); - currentUser['is_tmp'] ? loginModal.modal('show') : logoutModal.modal('show'); - }); - } -}); /** - * Returns preferred language specified in provided cid - * @param cid: provided conversation id - * @param inputType: type of the language preference to fetch: - * "incoming" - for external shouts, "outcoming" - for emitted shouts - * - * @return preferred lang by cid or "en" + * Gets message node from the message container + * @param messageContainer: DOM Message Container element to consider + * @param validateType: type of message to validate + * @return {HTMLElement} ID of the message */ -function getPreferredLanguage(cid, inputType = 'incoming') { - let preferredLang = 'en'; - try { - preferredLang = getChatLanguageMapping(cid, inputType); - } catch (e) { - console.warn(`Failed to getChatLanguageMapping - ${e}`) +const getMessageNode = (messageContainer, validateType = null) => { + let detectedType; + let node + if (messageContainer.getElementsByTagName('table').length > 0) { + detectedType = 'prompt'; + node = messageContainer.getElementsByTagName('table')[0]; + } else { + detectedType = 'plain' + node = messageContainer.getElementsByClassName('chat-body')[0].getElementsByClassName('chat-message')[0]; + } + if (validateType && validateType !== detectedType) { + return null; + } else { + return node; } - return preferredLang; } /** - * Returns preferred language specified in provided cid - * @param cid: provided conversation id - * @param lang: new preferred language to set - * @param inputType: type of the language preference to fetch: - * @param updateDB: to update user preferences in database - * @param updateDBOnly: to update user preferences in database only (without translation request) - * "incoming" - for external shouts, "outcoming" - for emitted shouts + * Adds new message to desired conversation id + * @param cid: desired conversation id + * @param userID: message sender id + * @param messageID: id of sent message (gets generated if null) + * @param messageText: text of the message + * @param timeCreated: timestamp for message creation + * @param repliedMessageID: id of the replied message (optional) + * @param attachments: array of attachments to add (optional) + * @param isAudio: is audio message (defaults to '0') + * @param isAnnouncement: is message an announcement (defaults to "0") + * @returns {Promise}: promise resolving id of added message, -1 if failed to resolve message id creation */ -async function setPreferredLanguage(cid, lang, inputType = 'incoming', updateDB = true, updateDBOnly = false) { - let isOk = false; - if (updateDB) { - const formData = new FormData(); - formData.append('lang', lang); - isOk = await fetchServer(`preferences/update_language/${cid}/${inputType}`, REQUEST_METHODS.POST, formData) - .then(res => { - return res.ok; - }); - } - if ((isOk || !updateDB) && !updateDBOnly) { - updateChatLanguageMapping(cid, inputType, lang); - const shoutIds = getMessagesOfCID(cid, MESSAGE_REFER_TYPE.ALL, 'plain', true); - await requestTranslation(cid, shoutIds, lang, inputType); +async function addNewMessage(cid, userID = null, messageID = null, messageText, timeCreated, repliedMessageID = null, attachments = [], isAudio = '0', isAnnouncement = '0') { + const messageList = getMessageListContainer(cid); + if (messageList) { + let userData; + const isMine = userID === currentUser['_id']; + if (isMine) { + userData = currentUser; + } else { + userData = await getUserData(userID); + } + if (!messageID) { + messageID = generateUUID(); + } + let messageHTML = await buildUserMessageHTML(userData, cid, messageID, messageText, timeCreated, isMine, isAudio, isAnnouncement); + const blankChat = messageList.getElementsByClassName('blank_chat'); + if (blankChat.length > 0) { + messageList.removeChild(blankChat[0]); + } + messageList.insertAdjacentHTML('beforeend', messageHTML); + resolveMessageAttachments(cid, messageID, attachments); + resolveUserReply(messageID, repliedMessageID); + addProfileDisplay(userID, cid, messageID, 'plain'); + return messageID; } } +const PROMPT_STATES = { + 1: 'RESP', + 2: 'DISC', + 3: 'VOTE' +} + /** - * Fetches supported languages + * Returns HTML Element representing user row in prompt + * @param promptID: target prompt id + * @param userID: target user id + * @return {HTMLElement}: HTML Element containing user prompt data */ -async function fetchSupportedLanguages() { - const query_url = `language_api/settings`; - return await fetchServer(query_url) - .then(response => { - if (response.ok) { - return response.json(); - } else { - console.log(`failed to fetch supported languages - ${response.statusText}`) - throw response.statusText; - } - }) - .then(data => { - configData['supportedLanguages'] = data['supported_languages']; - console.info(`supported languages updated - ${JSON.stringify(configData['supportedLanguages'])}`) - }).catch(err => console.warn('Failed to fulfill request due to error:', err)); +const getUserPromptTR = (promptID, userID) => { + return document.getElementById(`${promptID}_${userID}_prompt_row`); } /** - * Sends request for updating target conversation(s) content to the desired language - * @param cid: conversation id to bound request to - * @param shouts: list of shout ids to bound request to - * @param lang: language to apply (defaults to preferred language of each fetched conversation) - * @param inputType: type of the language input to apply (incoming or outcoming) - * @param translateToBaseLang: to translate provided items to the system base lang (based on preferred) + * Adds prompt message of specified user id + * @param cid: target conversation id + * @param userID: target submind user id + * @param messageText: message of submind + * @param promptId: target prompt id + * @param promptState: prompt state to consider */ -async function requestTranslation(cid = null, shouts = null, lang = null, inputType = 'incoming', translateToBaseLang = false) { - let requestBody = { - chat_mapping: {} - }; - if (cid && isDisplayed(cid)) { - lang = lang || getPreferredLanguage(cid, inputType); - if (lang !== 'en' && getMessagesOfCID(cid, MESSAGE_REFER_TYPE.ALL, 'plain').length > 0) { - setChatState(cid, 'updating', 'Applying New Language...'); - } - if (shouts && !Array.isArray(shouts)) { - shouts = [shouts]; - } - if (!shouts && inputType) { - shouts = getMessagesOfCID(cid, getMessageReferType(inputType), 'plain', true); - if (shouts.length === 0) { - console.log(`${cid} yet has no shouts matching type=${inputType}`); - setChatState(cid, 'active'); - return +async function addPromptMessage(cid, userID, messageText, promptId, promptState) { + const tableBody = document.getElementById(`${promptId}_tbody`); + if (await getCurrentSkin(cid) === CONVERSATION_SKINS.PROMPTS) { + try { + promptState = PROMPT_STATES[promptState].toLowerCase(); + if (!getUserPromptTR(promptId, userID)) { + const userData = await getUserData(userID); + const newUserRow = await buildSubmindHTML(promptId, userID, userData, '', '', ''); + tableBody.insertAdjacentHTML('beforeend', newUserRow); } - } - setDefault(requestBody.chat_mapping, cid, {}); - requestBody.chat_mapping[cid] = { - 'lang': lang, - 'shouts': shouts || [] - } - if (translateToBaseLang) { - requestBody.chat_mapping[cid]['source_lang'] = getPreferredLanguage(cid); - } - } else { - requestBody.chat_mapping = getChatLanguageMapping(); - if (!requestBody.chat_mapping) { - console.log('Chat mapping is undefined - returning'); - return + try { + const messageElem = document.getElementById(`${promptId}_${userID}_${promptState}`); + messageElem.innerText = messageText; + } catch (e) { + console.warn(`Failed to add prompt message (${cid},${userID}, ${messageText}, ${promptId}, ${promptState}) - ${e}`) + } + } catch (e) { + console.info(`Skipping message of invalid prompt state - ${promptState}`); } } - requestBody['user'] = currentUser['_id']; - requestBody['inputType'] = inputType; - console.debug(`requestBody = ${JSON.stringify(requestBody)}`); - socket.emitAuthorized('request_translate', requestBody); } + /** - * Sets selected language to the target language selector - * @param clickedItem: Language selector element clicked - * @param cid: target conversation id - * @param inputType: type of the language input to apply (incoming or outcoming) + * Returns first message id based on given element + * @param firstChild: DOM element of first message child */ -async function setSelectedLang(clickedItem, cid, inputType = "incoming") { - const selectedLangNode = document.getElementById(`language-selected-${cid}-${inputType}`); - const selectedLangList = document.getElementById(`language-list-${cid}-${inputType}`); - - // console.log('emitted lang update') - const preferredLang = getPreferredLanguage(cid, inputType); - const preferredLangProps = configData['supportedLanguages'][preferredLang]; - const newKey = clickedItem.getAttribute('data-lang'); - const newPreferredLangProps = configData['supportedLanguages'][newKey]; - - const direction = inputType === 'incoming' ? 'down' : 'up'; - selectedLangNode.innerHTML = await buildHTMLFromTemplate('selected_lang', { - 'key': newKey, - 'name': newPreferredLangProps['name'], - 'icon': newPreferredLangProps['icon'], - 'direction': direction - }) - if (preferredLangProps) { - selectedLangList.getElementsByClassName('lang-container')[0].insertAdjacentHTML('beforeend', await buildLangOptionHTML(cid, preferredLang, preferredLangProps['name'], preferredLangProps['icon'], inputType)); - } else { - console.warn(`"${preferredLang}" is set to be preferred but currently not supported`) - } - if (clickedItem.parentNode) { - clickedItem.parentNode.removeChild(clickedItem); - } - console.log(`cid=${cid};new preferredLang=${newKey}, inputType=${inputType}`); - await setPreferredLanguage(cid, newKey, inputType, true); - const insertedNode = document.getElementById(getLangOptionID(cid, preferredLang, inputType)); - insertedNode.addEventListener('click', async (e) => { - e.preventDefault(); - await setSelectedLang(insertedNode, cid, inputType); - }); +function getFirstMessageFromCID(firstChild) { + if (firstChild.classList.contains('prompt-item')) { + const promptTable = firstChild.getElementsByTagName('table')[0]; + const promptID = promptTable.id; + const promptTBody = promptTable.getElementsByTagName('tbody')[0]; + let currentRecentMessage = null; + let currentOldestTS = null; + Array.from(promptTBody.getElementsByTagName('tr')).forEach(tr => { + const submindID = tr.getAttribute('data-submind-id'); + ['resp', 'opinion', 'vote'].forEach(phase => { + const phaseElem = document.getElementById(`${promptID}_${submindID}_${phase}`); + if (phaseElem) { + let createdOn = phaseElem.getAttribute(`data-created-on`); + const messageID = phaseElem.getAttribute(`data-message-id`) + if (createdOn && messageID) { + createdOn = parseInt(createdOn); + if (!currentOldestTS || createdOn < currentOldestTS) { + currentOldestTS = createdOn; + currentRecentMessage = messageID; + } + } + } + }); + }); + return currentRecentMessage; + } else { + return getMessageNode(firstChild, 'plain')?.id; + } } /** - * Initialize language selector for conversation + * Gets list of the next n-older messages * @param cid: target conversation id - * @param inputType: type of the language input to apply (incoming or outcoming) + * @param skin: target conversation skin */ -async function initLanguageSelector(cid, inputType = "incoming") { - let preferredLang = getPreferredLanguage(cid, inputType); - const supportedLanguages = configData['supportedLanguages']; - if (!supportedLanguages.hasOwnProperty(preferredLang)) { - preferredLang = 'en'; - } - const selectedLangNode = document.getElementById(`language-selected-${cid}-${inputType}`); - const langList = document.getElementById(`language-list-${cid}-${inputType}`); - if (langList) { - const langListContainer = langList.getElementsByClassName('lang-container')[0] - - if (langListContainer) { - langListContainer.innerHTML = ""; - } - - // selectedLangNode.innerHTML = ""; - for (const [key, value] of Object.entries(supportedLanguages)) { - - if (key === preferredLang) { - const direction = inputType === 'incoming' ? 'down' : 'up'; - selectedLangNode.innerHTML = await buildHTMLFromTemplate('selected_lang', { - 'key': key, - 'name': value['name'], - 'icon': value['icon'], - 'direction': direction - }) - } else { - langListContainer.insertAdjacentHTML('beforeend', await buildLangOptionHTML(cid, key, value['name'], value['icon'], inputType)); - const itemNode = document.getElementById(getLangOptionID(cid, key, inputType)); - itemNode.addEventListener('click', async (e) => { - e.preventDefault(); - await setSelectedLang(itemNode, cid, inputType) +async function addOldMessages(cid, skin = CONVERSATION_SKINS.BASE) { + const messageContainer = getMessageListContainer(cid); + if (messageContainer.children.length > 0) { + for (let i = 0; i < messageContainer.children.length; i++) { + const firstMessageItem = messageContainer.children[i]; + const oldestMessageTS = await DBGateway.getInstance(DB_TABLES.CHAT_MESSAGES_PAGINATION).getItem(cid).then(res => res?.oldest_created_on || null); + if (oldestMessageTS) { + const numMessages = await getCurrentSkin(cid) === CONVERSATION_SKINS.PROMPTS ? 30 : 10; + await getConversationDataByInput(cid, skin, oldestMessageTS, numMessages).then(async conversationData => { + if (messageContainer) { + const userMessageList = getUserMessages(conversationData, null); + userMessageList.sort((a, b) => { + a['created_on'] - b['created_on']; + }).reverse(); + for (const message of userMessageList) { + message['cid'] = cid; + if (!isDisplayed(getMessageID(message))) { + const messageHTML = await messageHTMLFromData(message, skin); + messageContainer.insertAdjacentHTML('afterbegin', messageHTML); + } else { + console.debug(`!!message_id=${message["message_id"]} is already displayed`) + } + } + await initMessages(conversationData, skin); + } + }).then(_ => { + firstMessageItem.scrollIntoView({ + behavior: "smooth" + }); }); + break; + } else { + console.warn(`NONE first message id detected for cid=${cid}`) } } } } + /** - * Inits both incoming and outcoming language selectors - * @param cid: target conversation id + * Returns message id based on message type + * @param message: message object to check + * @returns {null|*} message id extracted if valid message type detected */ -const initLanguageSelectors = async (cid) => { - for (const inputType of ['incoming', 'outcoming']) { - await initLanguageSelector(cid, inputType); +const getMessageID = (message) => { + switch (message['message_type']) { + case 'plain': + return message['message_id']; + case 'prompt': + return message['_id']; + default: + console.warn(`Invalid message structure received - ${message}`); + return null; } } - -function getMessageReferType(inputType) { - return inputType === 'incoming' ? MESSAGE_REFER_TYPE.OTHERS : MESSAGE_REFER_TYPE.MINE; +/** + * Array of user messages in given conversation + * @param conversationData: Conversation Data object to fetch + * @param forceType: to force particular type of messages among the chat flow + */ +const getUserMessages = (conversationData, forceType = 'plain') => { + try { + let messages = Array.from(conversationData['chat_flow']); + if (forceType) { + messages = messages.filter(message => message['message_type'] === forceType); + } + return messages; + } catch { + return []; + } } - /** - * Sends request to server for chat language refreshing + * Initializes listener for loading old message on scrolling conversation box + * @param conversationData: Conversation Data object to fetch + * @param skin: conversation skin to apply */ -async function requestChatsLanguageRefresh() { - const languageMapping = currentUser?.preferences?.chat_language_mapping || {}; - console.log(`languageMapping=${JSON.stringify(languageMapping)}`) - for (const [cid, value] of Object.entries(languageMapping)) { - if (isDisplayed(cid)) { +function initLoadOldMessages(conversationData, skin) { + const cid = conversationData['_id']; + const messageList = getMessageListContainer(cid); + const messageListParent = messageList.parentElement; + setDefault(setDefault(conversationState, cid, {}), 'lastScrollY', 0); + messageListParent.addEventListener("scroll", async (e) => { + const oldScrollPosition = conversationState[cid]['scrollY']; + conversationState[cid]['scrollY'] = e.target.scrollTop; + if (oldScrollPosition > conversationState[cid]['scrollY'] && + !conversationState[cid]['all_messages_displayed'] && + conversationState[cid]['scrollY'] === 0) { + setChatState(cid, 'updating', 'Loading messages...') + await addOldMessages(cid, skin); for (const inputType of ['incoming', 'outcoming']) { - const lang = value[inputType] || 'en'; - if (lang !== 'en') { - await setPreferredLanguage(cid, lang, inputType, false); - } + await requestTranslation(cid, null, null, inputType); } + setTimeout(() => { + setChatState(cid, 'active'); + }, 700); } - } - console.log(`chatLanguageMapping=${JSON.stringify(getChatLanguageMapping())}`) + }); } /** - * Applies translation based on received data - * @param data: translation object received - * Note: data should be of format: - * { - * 'cid': {'message1':'translation of message 1', - * 'message2':'translation of message 2'} - * } + * Attaches event listener to display element's target user profile + * @param userID target user id + * @param elem target DOM element */ -async function applyTranslations(data) { - const inputType = setDefault(data, 'input_type', 'incoming'); - for (const [cid, messageTranslations] of Object.entries(data['translations'])) { - - if (!isDisplayed(cid)) { - console.log(`cid=${cid} is not displayed, skipping translations population`) - continue; - } +function attachTargetProfileDisplay(userID, elem) { + if (elem) { + elem.addEventListener('click', async (_) => { + if (userID) await showProfileModal(userID) + }); + } +} - setChatState(cid, 'active'); +/** + * Adds callback for showing profile information on profile avatar click + * @param userID target user id + * @param cid target conversation id + * @param messageId target message id + * @param messageType type of message to display + */ +function addProfileDisplay(userID, cid, messageId, messageType = 'plain') { + if (messageType === 'plain') { + attachTargetProfileDisplay(userID, document.getElementById(`${messageId}_avatar`)) + } else if (messageType === 'prompt') { + const promptTBody = document.getElementById(`${messageId}_tbody`); + const rows = promptTBody.getElementsByTagName('tr'); + Array.from(rows).forEach(row => { + attachTargetProfileDisplay(userID, Array.from(row.getElementsByTagName('td'))[0].getElementsByClassName('chat-img')[0]); + }) + } +} - console.debug(`Fetching translation of ${cid}`); - // console.debug(`translations=${JSON.stringify(messageTranslations)}`) - const messageTranslationsShouts = messageTranslations['shouts']; - if (messageTranslationsShouts) { - const messageReferType = getMessageReferType(inputType); - const messages = getMessagesOfCID(cid, messageReferType, 'plain'); - Array.from(messages).forEach(message => { - const messageID = message.id; - let repliedMessage = null; - let repliedMessageID = null; - try { - repliedMessage = message.getElementsByClassName('reply-placeholder')[0].getElementsByClassName('reply-text')[0]; - repliedMessageID = repliedMessage.getAttribute('data-replied-id') - // console.debug(`repliedMessageID=${repliedMessageID}`) - } catch (e) { - // console.debug(`replied message not found for ${messageID}`); - } - if (messageID in messageTranslationsShouts) { - message.getElementsByClassName('message-text')[0].innerHTML = messageTranslationsShouts[messageID]; - } - if (repliedMessageID && repliedMessageID in messageTranslationsShouts) { - repliedMessage.innerHTML = messageTranslationsShouts[repliedMessageID]; - } - }); - await initLanguageSelector(cid, inputType); - } - } +/** + * Inits addProfileDisplay() on each message of provided conversation + * @param conversationData - target conversation data + */ +function initProfileDisplay(conversationData) { + getUserMessages(conversationData, null).forEach(message => { + addProfileDisplay(message['user_id'], conversationData['_id'], getMessageID(message), message['message_type']); + }); } -const getChatLanguageMapping = (cid = null, inputType = null) => { - let res = setDefault(setDefault(currentUser, 'preferences', {}), 'chat_language_mapping', {}); - if (cid) { - res = setDefault(res, cid, {}); - } - if (inputType) { - res = setDefault(res, inputType, 'en'); +/** + * Inits pagination based on the oldest message creation timestamp + * @param conversationData - target conversation data + */ +async function initPagination(conversationData) { + const userMessages = getUserMessages(conversationData, null); + if (userMessages.length > 0) { + const oldestMessage = Math.min(...userMessages.map(msg => parseInt(msg.created_on))); + await DBGateway + .getInstance(DB_TABLES.CHAT_MESSAGES_PAGINATION) + .putItem({ + cid: conversationData['_id'], + oldest_created_on: oldestMessage + }) } - return res; } -const updateChatLanguageMapping = (cid, inputType, lang) => { - setDefault(currentUser.preferences.chat_language_mapping, cid, {})[inputType] = lang; - console.log(`cid=${cid},inputType=${inputType} updated to lang=${lang}`); + +/** + * Initializes messages based on provided conversation aata + * @param conversationData - JS Object containing conversation data of type: + * { + * '_id': 'id of conversation', + * 'conversation_name': 'title of the conversation', + * 'chat_flow': [{ + * 'user_nickname': 'nickname of sender', + * 'user_avatar': 'avatar of sender', + * 'message_id': 'id of the message', + * 'message_text': 'text of the message', + * 'is_audio': true if message is an audio message + * 'is_announcement': true if message is considered to be an announcement + * 'created_on': 'creation time of the message' + * }, ... (num of user messages returned)] + * } + * @param skin - target conversation skin to consider + */ +async function initMessages(conversationData, skin) { + initProfileDisplay(conversationData); + attachReplies(conversationData); + addAttachments(conversationData); + addCommunicationChannelTransformCallback(conversationData); + initLoadOldMessages(conversationData, skin); + await initPagination(conversationData); } /** - * Custom Event fired on supported languages init - * @type {CustomEvent} + * Emits user message to Socket IO Server + * @param textInputElem: DOM Element with input text (audio object if isAudio=true) + * @param cid: Conversation ID + * @param repliedMessageID: ID of replied message + * @param attachments: list of attachments file names + * @param isAudio: is audio message being emitted (defaults to '0') + * @param isAnnouncement: is message an announcement (defaults to '0') */ -const supportedLanguagesLoadedEvent = new CustomEvent("supportedLanguagesLoaded", { - "detail": "Event that is fired when system supported languages are loaded" -}); - -document.addEventListener('DOMContentLoaded', (_) => { - document.addEventListener('configLoaded', async (_) => { - await fetchSupportedLanguages().then(_ => document.dispatchEvent(supportedLanguagesLoadedEvent)); - }); -}); +function emitUserMessage(textInputElem, cid, repliedMessageID = null, attachments = [], isAudio = '0', isAnnouncement = '0') { + if (isAudio === '1' || textInputElem && textInputElem.value) { + const timeCreated = getCurrentTimestamp(); + let messageText; + if (isAudio === '1') { + messageText = textInputElem; + } else { + messageText = textInputElem.value; + } + addNewMessage(cid, currentUser['_id'], null, messageText, timeCreated, repliedMessageID, attachments, isAudio, isAnnouncement).then(async messageID => { + const preferredShoutLang = getPreferredLanguage(cid, 'outcoming'); + socket.emitAuthorized('user_message', { + 'cid': cid, + 'userID': currentUser['_id'], + 'messageText': messageText, + 'messageID': messageID, + 'lang': preferredShoutLang, + 'attachments': attachments, + 'isAudio': isAudio, + 'isAnnouncement': isAnnouncement, + 'timeCreated': timeCreated + }); + if (preferredShoutLang !== 'en') { + await requestTranslation(cid, messageID, 'en', 'outcoming', true); + } + addMessageTransformCallback(cid, messageID, isAudio); + }); + if (isAudio === '0') { + textInputElem.value = ""; + } + } +} /** * Generic function to play base64 audio file (currently only .wav format is supported) * @param audio_data: base64 encoded audio data @@ -1832,81 +1739,6 @@ async function addRecorder(conversationData) { }; } } -/** - * Gets time object from provided UNIX timestamp - * @param timestampCreated: UNIX timestamp (in seconds) - * @returns {string} string time (hours:minutes) - */ -function getTimeFromTimestamp(timestampCreated = 0) { - if (!timestampCreated) { - return '' - } - let date = new Date(timestampCreated * 1000); - let year = date.getFullYear().toString(); - let month = date.getMonth() + 1; - month = month >= 10 ? month.toString() : '0' + month.toString(); - let day = date.getDate(); - - day = day >= 10 ? day.toString() : '0' + day.toString(); - const hours = date.getHours().toString(); - let minutes = date.getMinutes(); - minutes = minutes >= 10 ? minutes.toString() : '0' + minutes.toString(); - return strFmtDate(year, month, day, hours, minutes, null); -} - -/** - * Composes date based on input params - * @param year: desired year - * @param month: desired month - * @param day: desired day - * @param hours: num of hours - * @param minutes: minutes - * @param seconds: seconds - * @return date string - */ -function strFmtDate(year, month, day, hours, minutes, seconds) { - let finalDate = ""; - if (year && month && day) { - finalDate += `${year}-${month}-${day}` - } - if (hours && minutes) { - finalDate += ` ${hours}:${minutes}` - if (seconds) { - finalDate += `:${seconds}` - } - } - return finalDate; -} -/** - * Downloads desired content - * @param content: content to download - * @param filename: name of the file to download - * @param contentType: type of the content - */ -function download(content, filename, contentType = 'application/octet-stream') { - if (content) { - const a = document.createElement('a'); - const blob = new Blob([content], { - 'type': contentType - }); - a.href = window.URL.createObjectURL(blob); - a.target = 'blank'; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(content); - } else { - console.warn('Skipping downloading as content is invalid') - } -} - -/** - * Handles error while loading the image data - * @param image: target image Node - */ -function handleImgError(image) { - image.parentElement.insertAdjacentHTML('afterbegin', `

${image.getAttribute('alt')}

`); - image.parentElement.removeChild(image); -} /** * Renders suggestions HTML */ @@ -1938,1901 +1770,2026 @@ async function renderSuggestions() { importConversationModalSuggestions.style.setProperty('display', 'inherit', 'important'); }); } -/** - * Object representing loaded HTML components mapping: - * - key: component name, - * - value: HTML template that should be populated with actual data) - * @type Object - */ -let loadedComponents = {} +let submindsState; -/** - * Fetches template context into provided html template - * @param html: HTML template - * @param templateContext: object containing context to fetch - * @return {string} HTML with fetched context - */ -function fetchTemplateContext(html, templateContext) { - for (const [key, value] of Object.entries(templateContext)) { - html = html.replaceAll('{' + key + '}', value); +function renderActiveSubminds(cid) { + if (!submindsState) { + console.log(`Subminds for CID ${cid} not yet loaded.`); + return; } - return html; -} + const loadingSpinner = document.getElementById(`${cid}-subminds-state-loading`); + if (loadingSpinner) { + loadingSpinner.classList.remove('d-flex'); + loadingSpinner.style.display = 'none'; + } + + const dropdownMenu = document.getElementById(`bot-list-${cid}`); + dropdownMenu.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + const table = document.getElementById(`${cid}-subminds-state-table`); + const entriesContainer = document.getElementById(`${cid}-subminds-state-entries`); + const buttonsContainer = document.getElementById(`${cid}-subminds-buttons`); + buttonsContainer.style.display = 'none'; + const cancelButton = document.getElementById(`${cid}-reset-button`); + const submitButton = document.getElementById(`${cid}-submit-button`); + + const { + subminds_per_cid: submindsPerCID, + connected_subminds: connectedSubminds + } = submindsState; + + const activeSubminds = submindsPerCID?.[cid]?.filter(submind => submind.status === 'active') || []; + const activeSubmindServices = new Set(activeSubminds.map(submind => submind.submind_id.slice(0, submind.submind_id.lastIndexOf('-')))) + + const banned_subminds = submindsPerCID?.[cid]?.filter(submind => submind.status === 'banned') || []; + const bannedSubmindIds = new Set(banned_subminds.map(submind => submind.submind_id)); + + const initialSubmindsState = []; + const processedServiceNames = []; + for (let [submindID, submindData] of Object.entries(connectedSubminds || {})) { + const serviceName = submindData.service_name; + const botType = submindData.bot_type; + if (botType === "submind" && !bannedSubmindIds.has(submindID) && !processedServiceNames.includes(serviceName)) { + processedServiceNames.push(serviceName) + initialSubmindsState.push({ + service_name: serviceName, + is_active: activeSubmindServices.has(serviceName) + }) + } + } + initialSubmindsState.sort((a, b) => { + return b.is_active - a.is_active; + }) + + let currentState = structuredClone(initialSubmindsState); + + const updateButtonVisibility = () => { + const hasChanges = initialSubmindsState.some((submind, index) => submind.is_active !== currentState[index].is_active); + buttonsContainer.style.display = hasChanges ? 'block' : 'none'; + }; + + table.style.display = ''; + entriesContainer.innerHTML = ''; + + initialSubmindsState.forEach((submind, index) => { + const row = document.createElement('tr'); + row.innerHTML = ` +${submind.service_name} + +
+ + +
+ +`; + + const checkbox = row.querySelector(`#toggle-${cid}-${submind.service_name}`); + checkbox.addEventListener('change', () => { + currentState[index].is_active = checkbox.checked; + updateButtonVisibility(); + }); + entriesContainer.appendChild(row); + }); + + cancelButton.onclick = () => { + currentState = structuredClone(initialSubmindsState); + currentState.forEach((submind, index) => { + const checkbox = document.getElementById(`toggle-${cid}-${submind.service_name}`); + checkbox.checked = (submind.is_active) ? "checked" : ''; + }); + updateButtonVisibility(); + }; + + submitButton.onclick = () => { + const modifiedSubminds = currentState.filter((current, index) => { + return current.is_active !== initialSubmindsState[index].is_active; + }); + + let subminds_to_remove = modifiedSubminds.filter(submind => !submind.is_active).map(submind => submind.service_name); + let subminds_to_add = modifiedSubminds.filter(submind => submind.is_active).map(submind => submind.service_name); -/** - * Builds HTML from passed params and template name - * @param templateName: name of the template to fetch - * @param templateContext: properties from template to fetch - * @param requestArgs: request string arguments (optional) - * @returns built template string - */ -async function buildHTMLFromTemplate(templateName, templateContext = {}, requestArgs = '') { - if (!configData['DISABLE_CACHING'] && loadedComponents.hasOwnProperty(templateName) && !requestArgs) { - const html = loadedComponents[templateName]; - return fetchTemplateContext(html, templateContext); - } else { - return await fetch(`${configData['currentURLBase']}/components/${templateName}?${requestArgs}`) - .then((response) => { - if (response.ok) { - return response.text(); - } - throw `template unreachable (HTTP STATUS:${response.status}: ${response.statusText})` - }) - .then((html) => { - if (!(configData['DISABLE_CACHING'] || loadedComponents.hasOwnProperty(templateName) || requestArgs)) { - loadedComponents[templateName] = html; - } - return fetchTemplateContext(html, templateContext); - }).catch(err => console.warn(`Failed to fetch template for ${templateName}: ${err}`)); - } -} + if (subminds_to_add.length !== 0 || subminds_to_remove.length !== 0) { + socket.emit('broadcast', { + msg_type: "update_participating_subminds", + "cid": cid, + "subminds_to_invite": subminds_to_add, + "subminds_to_kick": subminds_to_remove, + }); + } + const dropdownToggle = document.getElementById(`dropdownToggle-${cid}`); + if (dropdownToggle) dropdownToggle.click(); -/** - * Get Node id based on language key - * @param cid: desired conversation id - * @param key: language key (e.g. 'en') - * @param inputType: type of the language input to apply (incoming or outcoming) - * @return {string} ID of Node - */ -function getLangOptionID(cid, key, inputType = 'incoming') { - return `language-option-${cid}-${inputType}-${key}`; + buttonsContainer.style.display = 'none'; + }; } -/** - * Build language selection HTML based on provided params - * @param cid: desired conversation id - * @param key: language key (e.g 'en') - * @param name: name of the language (e.g. English) - * @param icon: language icon (refers to flag-icon specs) - * @param inputType: type of the language input to apply (incoming or outcoming) - * @return {string} formatted langSelectPattern - */ -async function buildLangOptionHTML(cid, key, name, icon, inputType) { - return await buildHTMLFromTemplate('lang_option', { - 'itemId': getLangOptionID(cid, key, inputType), - 'key': key, - 'name': name, - 'icon': icon - }) -} -/** - * Builds user message HTML - * @param userData: data of message sender - * @param cid: conversation id of target message - * @param messageID: id of user message - * @param messageText: text of user message - * @param timeCreated: date of creation - * @param isMine: if message was emitted by current user - * @param isAudio: if message is audio message (defaults to '0') - * @param isAnnouncement: is message if announcement (defaults to '0') - * @returns {string}: constructed HTML out of input params - */ -async function buildUserMessageHTML(userData, cid, messageID, messageText, timeCreated, isMine, isAudio = '0', isAnnouncement = '0') { - const messageTime = getTimeFromTimestamp(timeCreated); - let shortedNick = `${userData['nickname'][0]}${userData['nickname'][userData['nickname'].length - 1]}`; - let imageComponent = `

${shortedNick}

`; - // if (userData.hasOwnProperty('avatar') && userData['avatar']){ - // imageComponent = `${shortedNick}` - // } - const messageClass = isAnnouncement === '1' ? 'announcement' : isMine ? 'in' : 'out'; - const messageOrientation = isMine ? 'right' : 'left'; - let minificationEnabled = currentUser?.preferences?.minify_messages === '1' || await getCurrentSkin(cid) === CONVERSATION_SKINS.PROMPTS; - let templateSuffix = minificationEnabled ? '_minified' : ''; - const templateName = isAudio === '1' ? `user_message_audio${templateSuffix}` : `user_message${templateSuffix}`; - if (isAudio === '0') { - messageText = messageText.replaceAll('\n', '
'); - } - let statusIconHTML = ''; - let userTooltip = userData['nickname']; - if (userData?.is_bot === '1') { - statusIconHTML = ' ' - userTooltip = `bot ${userTooltip}` - } - return await buildHTMLFromTemplate(templateName, { - 'message_class': messageClass, - 'is_announcement': isAnnouncement, - 'image_component': imageComponent, - 'message_id': messageID, - 'user_tooltip': userTooltip, - 'nickname': userData['nickname'], - 'nickname_shrunk': shrinkToFit(userData['nickname'], 15, '..'), - 'status_icon': statusIconHTML, - 'message_text': messageText, - 'message_orientation': messageOrientation, - 'audio_url': `${configData["CHAT_SERVER_URL_BASE"]}/files/audio/${messageID}`, - 'message_time': messageTime - }); -} +function parseSubmindsState(data) { + submindsState = data; -/** - * - * @param nick: nickname to shorten - * @return {string} - shortened nickname - */ -const shrinkNickname = (nick) => { - return `${nick[0]}${nick[nick.length - 1]}`; + const cids = Object.keys(submindsState["subminds_per_cid"]) + if (cids.length === 0) { + setAllCountersToZero(); + } else { + for (const cid of cids) { + refreshSubmindsCount(cid); + } + } } - +let __inputFileList = {}; /** - * Builds Prompt Skin HTML for submind responses - * @param promptID: target prompt id - * @param submindID: user id of submind - * @param submindUserData: user data of submind - * @param submindResponse: Responding data of submind to incoming prompt - * @param submindOpinion: Discussion data of submind to incoming prompt - * @param submindVote: Vote data of submind in prompt - * @return {Promise} - Submind Data HTML populated with provided data + * Gets uploaded files from specified conversation id + * @param cid specified conversation id + * @return {*} list of files from specified cid if any */ -async function buildSubmindHTML(promptID, submindID, submindUserData, submindResponse, submindOpinion, submindVote) { - const userNickname = shrinkNickname(submindUserData['nickname']); - let tooltip = submindUserData['nickname']; - if (submindUserData['is_bot']) { - tooltip = `bot ${tooltip}`; - } - const phaseDataObjectMapping = { - 'response': submindResponse, - 'opinion': submindOpinion, - 'vote': submindVote - } - let templateData = { - 'prompt_id': promptID, - 'user_id': submindID, - 'user_first_name': submindUserData['first_name'], - 'user_last_name': submindUserData['last_name'], - 'user_nickname': submindUserData['nickname'], - 'user_nickname_shrunk': userNickname, - // 'user_avatar': `${configData["CHAT_SERVER_URL_BASE"]}/files/avatar/${submindID}`, - 'tooltip': tooltip - } - const submindPromptData = {} - for (const [k, v] of Object.entries(phaseDataObjectMapping)) { - submindPromptData[k] = v.message_text - submindPromptData[`${k}_message_id`] = v?.message_id - const dateCreated = getTimeFromTimestamp(v?.created_on); - submindPromptData[`${k}_created_on`] = v?.created_on; - submindPromptData[`${k}_created_on_tooltip`] = dateCreated ? `shouted on: ${dateCreated}` : `no ${k} from ${userNickname} in this prompt`; +function getUploadedFiles(cid) { + if (__inputFileList.hasOwnProperty(cid)) { + return __inputFileList[cid]; } - return await buildHTMLFromTemplate("prompt_participant", Object.assign(templateData, submindPromptData)); + return []; } - /** - * Gets winner text based on the provided winner data - * @param winner: provided winner - * @return {string} generated winner text + * Cleans uploaded files per conversation */ -const getPromptWinnerText = (winner) => { - let res; - if (winner) { - res = `Selected winner "${winner}"`; - } else { - res = 'Consensus not reached'; +function cleanUploadedFiles(cid) { + if (__inputFileList.hasOwnProperty(cid)) { + delete __inputFileList[cid]; } - return res; + const attachmentsButton = document.getElementById('file-input-' + cid); + attachmentsButton.value = ""; + const fileContainer = document.getElementById('filename-container-' + cid); + fileContainer.innerHTML = ""; } - /** - * Builds prompt HTML from received prompt data - * @param prompt: prompt object - * @return Prompt HTML - */ -async function buildPromptHTML(prompt) { - let submindsHTML = ""; - const promptData = prompt['data']; - if (prompt['is_completed'] === '0') { - promptData['winner'] = `Prompt in progress -
-Loading... -
` - } else { - promptData['winner'] = getPromptWinnerText(promptData['winner']); - } - const emptyAnswer = `

-

`; - for (const submindID of Array.from(setDefault(promptData, 'participating_subminds', []))) { - let submindUserData; - try { - const searchedKeys = ['proposed_responses', 'submind_opinions', 'votes']; - let isLegacy = false; - try { - submindUserData = prompt['user_mapping'][submindID][0]; - } catch (e) { - console.warn('Detected legacy prompt structure'); - submindUserData = { - 'nickname': submindID, - 'first_name': 'Klat', - 'last_name': 'User', - 'is_bot': '0' - } - isLegacy = true - } - const data = {} - searchedKeys.forEach(key => { - try { - const messageId = promptData[key][submindID]; - let value = null; - if (!isLegacy) { - value = prompt['message_mapping'][messageId][0]; - value['message_id'] = messageId; - } - if (!value) { - value = { - 'message_text': emptyAnswer - } - } - data[key] = value; - } catch (e) { - data[key] = { - 'message_text': emptyAnswer - }; - } - }); - submindsHTML += await buildSubmindHTML(prompt['_id'], submindID, submindUserData, - data.proposed_responses, data.submind_opinions, data.votes); - } catch (e) { - console.log(`Malformed data for ${submindID} (prompt_id=${prompt['_id']}) ex=${e}`); - } + * Adds File upload to specified cid + * @param cid: mentioned cid + * @param file: File object + */ +function addUpload(cid, file) { + if (!__inputFileList.hasOwnProperty(cid)) { + __inputFileList[cid] = []; } - return await buildHTMLFromTemplate("prompt_table", { - 'prompt_text': promptData['prompt_text'], - 'selected_winner': promptData['winner'], - 'prompt_participants_data': submindsHTML, - 'prompt_id': prompt['_id'], - 'cid': prompt['cid'], - 'message_time': prompt['created_on'] - }); + __inputFileList[cid].push(file); } /** - * Gets user message HTML from received message data object - * @param message: Message Object received - * @param skin: conversation skin - * @return {Promise} HTML by the provided message data + * Adds download request on attachment item click + * @param attachmentItem: desired attachment item + * @param cid: current conversation id + * @param messageID: current message id */ -async function messageHTMLFromData(message, skin = CONVERSATION_SKINS.BASE) { - if (skin === CONVERSATION_SKINS.PROMPTS && message['message_type'] === 'prompt') { - return buildPromptHTML(message); - } else { - const isMine = currentUser && message['user_nickname'] === currentUser['nickname']; - return buildUserMessageHTML({ - 'avatar': message['user_avatar'], - 'nickname': message['user_nickname'], - 'is_bot': message['user_is_bot'], - '_id': message['user_id'] - }, - message['cid'], - message['message_id'], - message['message_text'], - message['created_on'], - isMine, - message?.is_audio, - message?.is_announcement); +async function downloadAttachment(attachmentItem, cid, messageID) { + if (attachmentItem) { + const fileName = attachmentItem.getAttribute('data-file-name'); + const mime = attachmentItem.getAttribute('data-mime'); + const getFileURL = `files/${messageID}/get_attachment/${fileName}`; + await fetchServer(getFileURL).then(async response => { + response.ok ? + download(await response.blob(), fileName, mime) : + console.error(`No file data received for path, +cid=${cid};\n +message_id=${messageID};\n +file_name=${fileName}`) + }).catch(err => console.error(`Failed to fetch: ${getFileURL}: ${err}`)); } } /** - * Builds HTML for received conversation data - * @param conversationData: JS Object containing conversation data of type: - * { - * '_id': 'id of conversation', - * 'conversation_name': 'title of the conversation', - * 'chat_flow': [{ - * 'user_nickname': 'nickname of sender', - * 'user_avatar': 'avatar of sender', - * 'message_id': 'id of the message', - * 'message_text': 'text of the message', - * 'created_on': 'creation time of the message' - * }, ... (num of user messages returned)] - * } - * @param skin: conversation skin to build - * @returns {string} conversation HTML based on provided data + * Attaches message replies to initialized conversation + * @param conversationData: conversation data object */ -async function buildConversationHTML(conversationData = {}, skin = CONVERSATION_SKINS.BASE) { - const cid = conversationData['_id']; - const conversation_name = conversationData['conversation_name']; - let chatFlowHTML = ""; +function addAttachments(conversationData) { if (conversationData.hasOwnProperty('chat_flow')) { - for (const message of Array.from(conversationData['chat_flow'])) { - message['cid'] = cid; - chatFlowHTML += await messageHTMLFromData(message, skin); - // if (skin === CONVERSATION_SKINS.BASE) { - // } - } - } else { - chatFlowHTML += `
No messages in this chat yet...
`; + getUserMessages(conversationData).forEach(message => { + resolveMessageAttachments(conversationData['_id'], message['message_id'], message?.attachments); + }); } - const conversationNameShrunk = shrinkToFit(conversation_name, 6); - let nanoHeaderHTML = ''; - if (configData.client === CLIENTS.NANO) { - nanoHeaderHTML = await buildHTMLFromTemplate('nano_header', { - 'cid': cid - }) +} + +/** + * Activates attachments event listeners for message attachments in specified conversation + * @param cid: desired conversation id + * @param elem: parent element for attachment (defaults to document) + */ +function activateAttachments(cid, elem = null) { + if (!elem) { + elem = document; } - return await buildHTMLFromTemplate('conversation', { - 'cid': cid, - 'nano_header': nanoHeaderHTML, - 'conversation_name': conversation_name, - 'conversation_name_shrunk': conversationNameShrunk, - 'chat_flow': chatFlowHTML - }, `skin=${skin}`); + Array.from(elem.getElementsByClassName('attachment-item')).forEach(attachmentItem => { + attachmentItem.addEventListener('click', async (e) => { + e.preventDefault(); + const attachmentName = attachmentItem.getAttribute('data-file-name'); + try { + setChatState(cid, 'updating', `Downloading attachment file`); + await downloadAttachment(attachmentItem, cid, attachmentItem.parentNode.parentNode.id); + } catch (e) { + console.warn(`Failed to download attachment file - ${attachmentName} (${e})`) + } finally { + setChatState(cid, 'active'); + } + }); + }); } + /** - * Builds suggestion HTML - * @param cid: target conversation id - * @param name: target conversation name - * @return {Promise} HTML with fetched data + * Returns DOM element to include as file resolver based on its name + * @param filename: name of file to fetch + * @return {string}: resulting DOM element */ -const buildSuggestionHTML = async (cid, name) => { - return await buildHTMLFromTemplate('suggestion', { - 'cid': cid, - 'conversation_name': name - }) -}; -document.addEventListener('configLoaded', async (_) => { +function attachmentHTMLBasedOnFilename(filename) { - const buildVersion = configData?.["BUILD_VERSION"]; - const buildTS = configData?.["BUILD_TS"]; - if (buildVersion && buildTS) { - document.getElementById("app-version").innerText = `v${buildVersion} (${getTimeFromTimestamp(buildTS)})`; + let fSplitted = filename.split('.'); + if (fSplitted.length > 1) { + const extension = fSplitted.pop(); + const shrinkedName = shrinkToFit(filename, 12, `...${extension}`); + if (IMAGE_EXTENSIONS.includes(extension)) { + return ` ${shrinkedName}`; + } else { + return shrinkedName; + } } -}); + return shrinkToFit(filename, 12); +} + /** - * Displays modal bounded to the provided conversation id - * @param modalElem: modal to display - * @param cid: conversation id to consider + * Resolves attachments to the message + * @param cid: id of conversation + * @param messageID: id of user message + * @param attachments list of attachments received */ -function displayModalInCID(modalElem, cid) { - modalElem.modal('hide'); - $('.modal-backdrop').appendTo(`#${cid}`); - modalElem.modal('show'); -} -const DATABASES = { - CHATS: 'chats' -} -const DB_TABLES = { - CHAT_ALIGNMENT: 'chat_alignment', - MINIFY_SETTINGS: 'minify_settings', - CHAT_MESSAGES_PAGINATION: 'chat_messages_pagination' -} -const __db_instances = {} -const __db_definitions = { - [DATABASES.CHATS]: { - [DB_TABLES.CHAT_ALIGNMENT]: `cid, added_on, skin`, - [DB_TABLES.CHAT_MESSAGES_PAGINATION]: `cid, oldest_created_on` +function resolveMessageAttachments(cid, messageID, attachments = []) { + if (messageID) { + const messageElem = document.getElementById(messageID); + if (messageElem) { + const attachmentToggle = messageElem.getElementsByClassName('attachment-toggle')[0]; + if (attachments.length > 0) { + if (messageElem) { + const attachmentPlaceholder = messageElem.getElementsByClassName('attachments-placeholder')[0]; + attachments.forEach(attachment => { + const attachmentHTML = ` +${attachmentHTMLBasedOnFilename(attachment['name'])} +
`; + attachmentPlaceholder.insertAdjacentHTML('afterbegin', attachmentHTML); + }); + attachmentToggle.addEventListener('click', (e) => { + attachmentPlaceholder.style.display = attachmentPlaceholder.style.display === "none" ? "" : "none"; + }); + activateAttachments(cid, attachmentPlaceholder); + attachmentToggle.style.display = ""; + // attachmentPlaceholder.style.display = ""; + } + } else { + attachmentToggle.style.display = "none"; + } + } } } +let socket; + +const sioTriggeringEvents = ['configLoaded', 'configNanoLoaded']; + +sioTriggeringEvents.forEach(event => { + document.addEventListener(event, _ => { + socket = initSIO(); + }); +}); /** - * Gets database and table from name - * @param db: database name to get - * @param table: table name to get - * @return {Table} Dexie database object under specified table + * Inits socket io client listener by attaching relevant listeners on message channels + * @return {Socket} Socket IO client instance */ -const getDb = (db, table) => { - let _instance; - if (!Object.keys(__db_instances).includes(db)) { - _instance = new Dexie(name); - if (Object.keys(__db_definitions).includes(db)) { - _instance.version(1).stores(__db_definitions[db]); +function initSIO() { + + const sioServerURL = configData['CHAT_SERVER_URL_BASE']; + + const socket = io( + sioServerURL, { + extraHeaders: { + "session": getSessionToken() + } } - __db_instances[db] = _instance; - } else { - _instance = __db_instances[db]; + ); + + socket.__proto__.emitAuthorized = (event, data) => { + socket.io.opts.extraHeaders.session = getSessionToken(); + return socket.emit(event, data); } - return _instance[table]; -} + socket.on('auth_expired', () => { + if (currentUser && Object.keys(currentUser).length > 0) { + console.log('Authorization Token expired, refreshing...') + location.reload(); + } + }); + + socket.on('connect', () => { + console.info(`Socket IO Connected to Server: ${sioServerURL}`) + }); + + socket.on("connect_error", (err) => { + console.log(`connect_error due to ${err.message}`); + }); -class DBGateway { - constructor(db, table) { - this.db = db; - this.table = table; + socket.on('new_prompt_created', async (prompt) => { + const messageContainer = getMessageListContainer(prompt['cid']); + const promptID = prompt['_id']; + if (await getCurrentSkin(prompt['cid']) === CONVERSATION_SKINS.PROMPTS) { + if (!document.getElementById(promptID)) { + const messageHTML = await buildPromptHTML(prompt); + messageContainer.insertAdjacentHTML('beforeend', messageHTML); + } + } + }); - this._db_instance = getDb(this.db, this.table); - this._db_columns_definitions = __db_definitions[this.db][this.table] - this._db_key = this._db_columns_definitions.split(',')[0] - } + socket.on('new_message', async (data) => { + if (await getCurrentSkin(data.cid) === CONVERSATION_SKINS.PROMPTS && data?.prompt_id) { + console.debug('Skipping prompt-related message') + return + } + // console.debug('received new_message -> ', data) + const preferredLang = getPreferredLanguage(data['cid']); + if (data?.lang !== preferredLang) { + requestTranslation(data['cid'], data['messageID']).catch(err => console.error(`Failed to request translation of cid=${data['cid']} messageID=${data['messageID']}: ${err}`)); + } + addNewMessage(data['cid'], data['userID'], data['messageID'], data['messageText'], data['timeCreated'], data['repliedMessage'], data['attachments'], data?.isAudio, data?.isAnnouncement) + .then(_ => addMessageTransformCallback(data['cid'], data['messageID'], data?.isAudio)) + .catch(err => console.error('Error occurred while adding new message: ', err)); + }); - async getItem(key = "") { - return await this._db_instance.where({ - [this._db_key]: key - }).first(); - } + socket.on('new_prompt_message', async (message) => { + await addPromptMessage(message['cid'], message['userID'], message['messageText'], message['promptID'], message['promptState']) + .catch(err => console.error('Error occurred while adding new prompt data: ', err)); + }); - async listItems(orderBy = "") { - let expression = this._db_instance; - if (orderBy !== "") { - expression = expression.orderBy(orderBy) + socket.on('set_prompt_completed', async (data) => { + const promptID = data['prompt_id']; + const promptElem = document.getElementById(promptID); + console.info(`setting prompt_id=${promptID} as completed`); + if (promptElem) { + const promptWinner = document.getElementById(`${promptID}_winner`); + promptWinner.innerHTML = getPromptWinnerText(data['winner']); + } else { + console.warn(`Failed to get HTML element from prompt_id=${promptID}`); } - return await expression.toArray(); - } + }); - async putItem(data = {}) { - return await this._db_instance.put(data, [data[this._db_key]]) - } + socket.on('translation_response', async (data) => { + console.debug('translation_response: ', data) + await applyTranslations(data); + }); - updateItem(data = {}) { - const key = data[this._db_key] - delete data[this._db_key] - return this._db_instance.update(key, data); - } + socket.on('subminds_state', async (data) => { + console.debug('subminds_state: ', data) + parseSubmindsState(data); + }); - async deleteItem(key = "") { - return await this._db_instance.where({ - [this._db_key]: key - }).delete(); - } + socket.on('incoming_tts', (data) => { + console.debug('received incoming stt audio'); + playTTS(data['cid'], data['lang'], data['audio_data']); + }); - static getInstance(table) { - return new DBGateway(DATABASES.CHATS, table); - } + socket.on('incoming_stt', (data) => { + console.debug('received incoming stt response'); + showSTT(data['message_id'], data['lang'], data['message_text']); + }); + + // socket.on('updated_shouts', async (data) =>{ + // const inputType = data['input_type']; + // for (const [cid, shouts] of Object.entries(data['translations'])){ + // if (await getCurrentSkin(cid) === CONVERSATION_SKINS.BASE){ + // await requestTranslation(cid, shouts, null, inputType); + // } + // } + // }); + + return socket; } /** - * Adds speaking callback for the message - * @param cid: id of the conversation - * @param messageID: id of the message + * Object representing loaded HTML components mapping: + * - key: component name, + * - value: HTML template that should be populated with actual data) + * @type Object */ -function addTTSCallback(cid, messageID) { - const speakingButton = document.getElementById(`${messageID}_speak`); - if (speakingButton) { - speakingButton.addEventListener('click', (e) => { - e.preventDefault(); - getTTS(cid, messageID, getPreferredLanguage(cid)); - setChatState(cid, 'updating', `Fetching TTS...`) - }); - } -} +let loadedComponents = {} /** - * Adds speaking callback for the message - * @param cid: id of the conversation - * @param messageID: id of the message + * Fetches template context into provided html template + * @param html: HTML template + * @param templateContext: object containing context to fetch + * @return {string} HTML with fetched context */ -function addSTTCallback(cid, messageID) { - const sttButton = document.getElementById(`${messageID}_text`); - if (sttButton) { - sttButton.addEventListener('click', (e) => { - e.preventDefault(); - const sttContent = document.getElementById(`${messageID}-stt`); - if (sttContent) { - sttContent.innerHTML = `
-Waiting for STT...
-Loading... -
-
`; - sttContent.style.setProperty('display', 'block', 'important'); - getSTT(cid, messageID, getPreferredLanguage(cid)); - } - }); +function fetchTemplateContext(html, templateContext) { + for (const [key, value] of Object.entries(templateContext)) { + html = html.replaceAll('{' + key + '}', value); } + return html; } /** - * Attaches STT capabilities for audio messages and TTS capabilities for text messages - * @param cid: parent conversation id - * @param messageID: target message id - * @param isAudio: if its an audio message (defaults to '0') + * Builds HTML from passed params and template name + * @param templateName: name of the template to fetch + * @param templateContext: properties from template to fetch + * @param requestArgs: request string arguments (optional) + * @returns built template string */ -function addMessageTransformCallback(cid, messageID, isAudio = '0') { - if (isAudio === '1') { - addSTTCallback(cid, messageID); +async function buildHTMLFromTemplate(templateName, templateContext = {}, requestArgs = '') { + if (!configData['DISABLE_CACHING'] && loadedComponents.hasOwnProperty(templateName) && !requestArgs) { + const html = loadedComponents[templateName]; + return fetchTemplateContext(html, templateContext); } else { - addTTSCallback(cid, messageID); + return await fetch(`${configData['currentURLBase']}/components/${templateName}?${requestArgs}`) + .then((response) => { + if (response.ok) { + return response.text(); + } + throw `template unreachable (HTTP STATUS:${response.status}: ${response.statusText})` + }) + .then((html) => { + if (!(configData['DISABLE_CACHING'] || loadedComponents.hasOwnProperty(templateName) || requestArgs)) { + loadedComponents[templateName] = html; + } + return fetchTemplateContext(html, templateContext); + }).catch(err => console.warn(`Failed to fetch template for ${templateName}: ${err}`)); } } /** - * Attaches STT capabilities for audio messages and TTS capabilities for text messages - * @param conversationData: conversation data object + * Get Node id based on language key + * @param cid: desired conversation id + * @param key: language key (e.g. 'en') + * @param inputType: type of the language input to apply (incoming or outcoming) + * @return {string} ID of Node */ -function addCommunicationChannelTransformCallback(conversationData) { - if (conversationData.hasOwnProperty('chat_flow')) { - getUserMessages(conversationData).forEach(message => { - addMessageTransformCallback(conversationData['_id'], message['message_id'], message?.is_audio); - }); - } +function getLangOptionID(cid, key, inputType = 'incoming') { + return `language-option-${cid}-${inputType}-${key}`; } + /** - * Resolves user reply on message - * @param replyID: id of user reply - * @param repliedID id of replied message + * Build language selection HTML based on provided params + * @param cid: desired conversation id + * @param key: language key (e.g 'en') + * @param name: name of the language (e.g. English) + * @param icon: language icon (refers to flag-icon specs) + * @param inputType: type of the language input to apply (incoming or outcoming) + * @return {string} formatted langSelectPattern */ -function resolveUserReply(replyID, repliedID) { - if (repliedID) { - const repliedElem = document.getElementById(repliedID); - if (repliedElem) { - let repliedText = repliedElem.getElementsByClassName('message-text')[0].innerText; - repliedText = shrinkToFit(repliedText, 15); - const replyHTML = ` -${repliedText} -`; - const replyPlaceholder = document.getElementById(replyID).getElementsByClassName('reply-placeholder')[0]; - replyPlaceholder.insertAdjacentHTML('afterbegin', replyHTML); - attachReplyHighlighting(replyPlaceholder.getElementsByClassName('reply-text')[0]); - } +async function buildLangOptionHTML(cid, key, name, icon, inputType) { + return await buildHTMLFromTemplate('lang_option', { + 'itemId': getLangOptionID(cid, key, inputType), + 'key': key, + 'name': name, + 'icon': icon + }) +} + +/** + * Builds user message HTML + * @param userData: data of message sender + * @param cid: conversation id of target message + * @param messageID: id of user message + * @param messageText: text of user message + * @param timeCreated: date of creation + * @param isMine: if message was emitted by current user + * @param isAudio: if message is audio message (defaults to '0') + * @param isAnnouncement: is message if announcement (defaults to '0') + * @returns {string}: constructed HTML out of input params + */ +async function buildUserMessageHTML(userData, cid, messageID, messageText, timeCreated, isMine, isAudio = '0', isAnnouncement = '0') { + const messageTime = getTimeFromTimestamp(timeCreated); + let shortedNick = `${userData['nickname'][0]}${userData['nickname'][userData['nickname'].length - 1]}`; + let imageComponent = `

${shortedNick}

`; + // if (userData.hasOwnProperty('avatar') && userData['avatar']){ + // imageComponent = `${shortedNick}` + // } + const messageClass = isAnnouncement === '1' ? 'announcement' : isMine ? 'in' : 'out'; + const messageOrientation = isMine ? 'right' : 'left'; + let minificationEnabled = currentUser?.preferences?.minify_messages === '1' || await getCurrentSkin(cid) === CONVERSATION_SKINS.PROMPTS; + let templateSuffix = minificationEnabled ? '_minified' : ''; + const templateName = isAudio === '1' ? `user_message_audio${templateSuffix}` : `user_message${templateSuffix}`; + if (isAudio === '0') { + messageText = messageText.replaceAll('\n', '
'); + } + let statusIconHTML = ''; + let userTooltip = userData['nickname']; + if (userData?.is_bot === '1') { + statusIconHTML = ' ' + userTooltip = `bot ${userTooltip}` } + return await buildHTMLFromTemplate(templateName, { + 'message_class': messageClass, + 'is_announcement': isAnnouncement, + 'image_component': imageComponent, + 'message_id': messageID, + 'user_tooltip': userTooltip, + 'nickname': userData['nickname'], + 'nickname_shrunk': shrinkToFit(userData['nickname'], 15, '..'), + 'status_icon': statusIconHTML, + 'message_text': messageText, + 'message_orientation': messageOrientation, + 'audio_url': `${configData["CHAT_SERVER_URL_BASE"]}/files/audio/${messageID}`, + 'message_time': messageTime + }); } /** - * Attaches reply highlighting for reply item - * @param replyItem reply item element + * + * @param nick: nickname to shorten + * @return {string} - shortened nickname */ -function attachReplyHighlighting(replyItem) { - replyItem.addEventListener('click', (e) => { - const repliedItem = document.getElementById(replyItem.getAttribute('data-replied-id')); - const backgroundParent = repliedItem.parentElement.parentElement; - repliedItem.scrollIntoView(); - backgroundParent.classList.remove('message-selected'); - setTimeout(() => backgroundParent.classList.add('message-selected'), 500); - }); +const shrinkNickname = (nick) => { + return `${nick[0]}${nick[nick.length - 1]}`; } + /** - * Attaches message replies to initialized conversation - * @param conversationData: conversation data object + * Builds Prompt Skin HTML for submind responses + * @param promptID: target prompt id + * @param submindID: user id of submind + * @param submindUserData: user data of submind + * @param submindResponse: Responding data of submind to incoming prompt + * @param submindOpinion: Discussion data of submind to incoming prompt + * @param submindVote: Vote data of submind in prompt + * @return {Promise} - Submind Data HTML populated with provided data */ -function attachReplies(conversationData) { - if (conversationData.hasOwnProperty('chat_flow')) { - getUserMessages(conversationData).forEach(message => { - resolveUserReply(message['message_id'], message?.replied_message); - }); - Array.from(document.getElementsByClassName('reply-text')).forEach(replyItem => { - attachReplyHighlighting(replyItem); - }); +async function buildSubmindHTML(promptID, submindID, submindUserData, submindResponse, submindOpinion, submindVote) { + const userNickname = shrinkNickname(submindUserData['nickname']); + let tooltip = submindUserData['nickname']; + if (submindUserData['is_bot']) { + tooltip = `bot ${tooltip}`; + } + const phaseDataObjectMapping = { + 'response': submindResponse, + 'opinion': submindOpinion, + 'vote': submindVote + } + let templateData = { + 'prompt_id': promptID, + 'user_id': submindID, + 'user_first_name': submindUserData['first_name'], + 'user_last_name': submindUserData['last_name'], + 'user_nickname': submindUserData['nickname'], + 'user_nickname_shrunk': userNickname, + // 'user_avatar': `${configData["CHAT_SERVER_URL_BASE"]}/files/avatar/${submindID}`, + 'tooltip': tooltip + } + const submindPromptData = {} + for (const [k, v] of Object.entries(phaseDataObjectMapping)) { + submindPromptData[k] = v.message_text + submindPromptData[`${k}_message_id`] = v?.message_id + const dateCreated = getTimeFromTimestamp(v?.created_on); + submindPromptData[`${k}_created_on`] = v?.created_on; + submindPromptData[`${k}_created_on_tooltip`] = dateCreated ? `shouted on: ${dateCreated}` : `no ${k} from ${userNickname} in this prompt`; } + return await buildHTMLFromTemplate("prompt_participant", Object.assign(templateData, submindPromptData)); } -let userSettingsModal; -let applyUserSettings; -let minifyMessagesCheck; -let settingsLink; + /** - * Displays relevant user settings section based on provided name - * @param name: name of the section to display + * Gets winner text based on the provided winner data + * @param winner: provided winner + * @return {string} generated winner text */ -const displaySection = (name) => { - Array.from(document.getElementsByClassName('user-settings-section')).forEach(elem => { - elem.hidden = true; - }); - const elem = document.getElementById(`user-settings-${name}-section`); - elem.hidden = false; +const getPromptWinnerText = (winner) => { + let res; + if (winner) { + res = `Selected winner "${winner}"`; + } else { + res = 'Consensus not reached'; + } + return res; } + /** - * Displays user settings based on received preferences - * @param preferences + * Builds prompt HTML from received prompt data + * @param prompt: prompt object + * @return Prompt HTML */ -const displayUserSettings = (preferences) => { - if (preferences) { - minifyMessagesCheck.checked = preferences?.minify_messages === '1' +async function buildPromptHTML(prompt) { + let submindsHTML = ""; + const promptData = prompt['data']; + if (prompt['is_completed'] === '0') { + promptData['winner'] = `Prompt in progress +
+Loading... +
` + } else { + promptData['winner'] = getPromptWinnerText(promptData['winner']); + } + const emptyAnswer = `

-

`; + for (const submindID of Array.from(setDefault(promptData, 'participating_subminds', []))) { + let submindUserData; + try { + const searchedKeys = ['proposed_responses', 'submind_opinions', 'votes']; + let isLegacy = false; + try { + submindUserData = prompt['user_mapping'][submindID][0]; + } catch (e) { + console.warn('Detected legacy prompt structure'); + submindUserData = { + 'nickname': submindID, + 'first_name': 'Klat', + 'last_name': 'User', + 'is_bot': '0' + } + isLegacy = true + } + const data = {} + searchedKeys.forEach(key => { + try { + const messageId = promptData[key][submindID]; + let value = null; + if (!isLegacy) { + value = prompt['message_mapping'][messageId][0]; + value['message_id'] = messageId; + } + if (!value) { + value = { + 'message_text': emptyAnswer + } + } + data[key] = value; + } catch (e) { + data[key] = { + 'message_text': emptyAnswer + }; + } + }); + submindsHTML += await buildSubmindHTML(prompt['_id'], submindID, submindUserData, + data.proposed_responses, data.submind_opinions, data.votes); + } catch (e) { + console.log(`Malformed data for ${submindID} (prompt_id=${prompt['_id']}) ex=${e}`); + } } + return await buildHTMLFromTemplate("prompt_table", { + 'prompt_text': promptData['prompt_text'], + 'selected_winner': promptData['winner'], + 'prompt_participants_data': submindsHTML, + 'prompt_id': prompt['_id'], + 'cid': prompt['cid'], + 'message_time': prompt['created_on'] + }); } /** - * Initialises section of settings based on provided name - * @param sectionName: name of the section provided + * Gets user message HTML from received message data object + * @param message: Message Object received + * @param skin: conversation skin + * @return {Promise} HTML by the provided message data */ -const initSettingsSection = async (sectionName) => { - await refreshCurrentUser(false) - .then(userData => displayUserSettings(userData?.preferences)) - .then(_ => displaySection(sectionName)); +async function messageHTMLFromData(message, skin = CONVERSATION_SKINS.BASE) { + if (skin === CONVERSATION_SKINS.PROMPTS && message['message_type'] === 'prompt') { + return buildPromptHTML(message); + } else { + const isMine = currentUser && message['user_nickname'] === currentUser['nickname']; + return buildUserMessageHTML({ + 'avatar': message['user_avatar'], + 'nickname': message['user_nickname'], + 'is_bot': message['user_is_bot'], + '_id': message['user_id'] + }, + message['cid'], + message['message_id'], + message['message_text'], + message['created_on'], + isMine, + message?.is_audio, + message?.is_announcement); + } } /** - * Initialises User Settings Modal + * Builds HTML for received conversation data + * @param conversationData: JS Object containing conversation data of type: + * { + * '_id': 'id of conversation', + * 'conversation_name': 'title of the conversation', + * 'chat_flow': [{ + * 'user_nickname': 'nickname of sender', + * 'user_avatar': 'avatar of sender', + * 'message_id': 'id of the message', + * 'message_text': 'text of the message', + * 'created_on': 'creation time of the message' + * }, ... (num of user messages returned)] + * } + * @param skin: conversation skin to build + * @returns {string} conversation HTML based on provided data */ -const initSettingsModal = async () => { - Array.from(document.getElementsByClassName('nav-user-settings')).forEach(navItem => { - navItem.addEventListener('click', async (e) => { - await initSettingsSection(navItem.getAttribute('data-section-name')); - }); - }); +async function buildConversationHTML(conversationData = {}, skin = CONVERSATION_SKINS.BASE) { + const cid = conversationData['_id']; + const conversation_name = conversationData['conversation_name']; + let chatFlowHTML = ""; + if (conversationData.hasOwnProperty('chat_flow')) { + for (const message of Array.from(conversationData['chat_flow'])) { + message['cid'] = cid; + chatFlowHTML += await messageHTMLFromData(message, skin); + // if (skin === CONVERSATION_SKINS.BASE) { + // } + } + } else { + chatFlowHTML += `
No messages in this chat yet...
`; + } + const conversationNameShrunk = shrinkToFit(conversation_name, 6); + let nanoHeaderHTML = ''; + if (configData.client === CLIENTS.NANO) { + nanoHeaderHTML = await buildHTMLFromTemplate('nano_header', { + 'cid': cid + }) + } + return await buildHTMLFromTemplate('conversation', { + 'cid': cid, + 'nano_header': nanoHeaderHTML, + 'conversation_name': conversation_name, + 'conversation_name_shrunk': conversationNameShrunk, + 'chat_flow': chatFlowHTML + }, `skin=${skin}`); } /** - * Applies new settings to current user + * Builds suggestion HTML + * @param cid: target conversation id + * @param name: target conversation name + * @return {Promise} HTML with fetched data */ -const applyNewSettings = async () => { - const newUserSettings = { - 'minify_messages': minifyMessagesCheck.checked ? '1' : '0' - }; - const query_url = 'preferences/update' - await fetchServer(query_url, REQUEST_METHODS.POST, newUserSettings, true).then(async response => { - const responseJson = await response.json(); - if (response.ok) { - location.reload(); - } else { - displayAlert(document.getElementById(`userSettingsModalBody`), - `${responseJson['msg']}`, - 'danger'); - } - }); -} - -function initSettings(elem) { - elem.addEventListener('click', async (e) => { - await initSettingsModal(); - userSettingsModal.modal('show'); - }); +const buildSuggestionHTML = async (cid, name) => { + return await buildHTMLFromTemplate('suggestion', { + 'cid': cid, + 'conversation_name': name + }) +}; +/** + * Returns preferred language specified in provided cid + * @param cid: provided conversation id + * @param inputType: type of the language preference to fetch: + * "incoming" - for external shouts, "outcoming" - for emitted shouts + * + * @return preferred lang by cid or "en" + */ +function getPreferredLanguage(cid, inputType = 'incoming') { + let preferredLang = 'en'; + try { + preferredLang = getChatLanguageMapping(cid, inputType); + } catch (e) { + console.warn(`Failed to getChatLanguageMapping - ${e}`) + } + return preferredLang; } /** - * Initialise user settings links based on the current client + * Returns preferred language specified in provided cid + * @param cid: provided conversation id + * @param lang: new preferred language to set + * @param inputType: type of the language preference to fetch: + * @param updateDB: to update user preferences in database + * @param updateDBOnly: to update user preferences in database only (without translation request) + * "incoming" - for external shouts, "outcoming" - for emitted shouts */ -const initSettingsLinks = () => { - if (configData.client === CLIENTS.NANO) { - console.log('initialising settings link for ', Array.from(document.getElementsByClassName('settings-link')).length, ' elements') - Array.from(document.getElementsByClassName('settings-link')).forEach(elem => { - initSettings(elem); - }); - } else { - initSettings(document.getElementById('settingsLink')); +async function setPreferredLanguage(cid, lang, inputType = 'incoming', updateDB = true, updateDBOnly = false) { + let isOk = false; + if (updateDB) { + const formData = new FormData(); + formData.append('lang', lang); + isOk = await fetchServer(`preferences/update_language/${cid}/${inputType}`, REQUEST_METHODS.POST, formData) + .then(res => { + return res.ok; + }); + } + if ((isOk || !updateDB) && !updateDBOnly) { + updateChatLanguageMapping(cid, inputType, lang); + const shoutIds = getMessagesOfCID(cid, MESSAGE_REFER_TYPE.ALL, 'plain', true); + await requestTranslation(cid, shoutIds, lang, inputType); } } -document.addEventListener('DOMContentLoaded', (_) => { - if (configData.client === CLIENTS.MAIN) { - userSettingsModal = $('#userSettingsModal'); - applyUserSettings = document.getElementById('applyUserSettings'); - minifyMessagesCheck = document.getElementById('minifyMessages'); - applyUserSettings.addEventListener('click', async (e) => await applyNewSettings()); - settingsLink = document.getElementById('settingsLink'); - settingsLink.addEventListener('click', async (e) => { - e.preventDefault(); - await initSettingsModal(); - userSettingsModal.modal('show'); - }); - } else { - document.addEventListener('modalsLoaded', (e) => { - userSettingsModal = $('#userSettingsModal'); - applyUserSettings = document.getElementById('applyUserSettings'); - minifyMessagesCheck = document.getElementById('minifyMessages'); - applyUserSettings.addEventListener('click', async (e) => await applyNewSettings()); - if (configData.client === CLIENTS.MAIN) { - initSettingsLinks(); +/** + * Fetches supported languages + */ +async function fetchSupportedLanguages() { + const query_url = `language_api/settings`; + return await fetchServer(query_url) + .then(response => { + if (response.ok) { + return response.json(); + } else { + console.log(`failed to fetch supported languages - ${response.statusText}`) + throw response.statusText; } - }); - - document.addEventListener('nanoChatsLoaded', (e) => { - setTimeout(() => initSettingsLinks(), 1000); }) - } -}); -const MessageScrollPosition = { - START: 'START', - END: 'END', - MIDDLE: 'MIDDLE', -}; + .then(data => { + configData['supportedLanguages'] = data['supported_languages']; + console.info(`supported languages updated - ${JSON.stringify(configData['supportedLanguages'])}`) + }).catch(err => console.warn('Failed to fulfill request due to error:', err)); +} /** - * Gets current message list scroller position based on first and last n-items visibility - * @param messageList: Container of messages - * @param numElements: number of first and last elements to check for visibility - * @param assertOnly: check only for one of the scroll position (preventing ambiguity if its a start or the end) - * @return {string} MessageScrollPosition from Enum + * Sends request for updating target conversation(s) content to the desired language + * @param cid: conversation id to bound request to + * @param shouts: list of shout ids to bound request to + * @param lang: language to apply (defaults to preferred language of each fetched conversation) + * @param inputType: type of the language input to apply (incoming or outcoming) + * @param translateToBaseLang: to translate provided items to the system base lang (based on preferred) */ -function getMessageScrollPosition(messageList, numElements = 3, assertOnly = null) { - numElements = Math.min(messageList.children.length, numElements); - if (numElements > 0) { - for (let i = 1; i <= numElements; i++) { - if (!(assertOnly === MessageScrollPosition.START) && - isInViewport(messageList.children[messageList.children.length - i])) { - return MessageScrollPosition.END; - } - if (!(assertOnly === MessageScrollPosition.END) && isInViewport(messageList.children[i - 1])) { - return MessageScrollPosition.START; +async function requestTranslation(cid = null, shouts = null, lang = null, inputType = 'incoming', translateToBaseLang = false) { + let requestBody = { + chat_mapping: {} + }; + if (cid && isDisplayed(cid)) { + lang = lang || getPreferredLanguage(cid, inputType); + if (lang !== 'en' && getMessagesOfCID(cid, MESSAGE_REFER_TYPE.ALL, 'plain').length > 0) { + setChatState(cid, 'updating', 'Applying New Language...'); + } + if (shouts && !Array.isArray(shouts)) { + shouts = [shouts]; + } + if (!shouts && inputType) { + shouts = getMessagesOfCID(cid, getMessageReferType(inputType), 'plain', true); + if (shouts.length === 0) { + console.log(`${cid} yet has no shouts matching type=${inputType}`); + setChatState(cid, 'active'); + return } } + setDefault(requestBody.chat_mapping, cid, {}); + requestBody.chat_mapping[cid] = { + 'lang': lang, + 'shouts': shouts || [] + } + if (translateToBaseLang) { + requestBody.chat_mapping[cid]['source_lang'] = getPreferredLanguage(cid); + } + } else { + requestBody.chat_mapping = getChatLanguageMapping(); + if (!requestBody.chat_mapping) { + console.log('Chat mapping is undefined - returning'); + return + } } - return MessageScrollPosition.MIDDLE; + requestBody['user'] = currentUser['_id']; + requestBody['inputType'] = inputType; + console.debug(`requestBody = ${JSON.stringify(requestBody)}`); + socket.emitAuthorized('request_translate', requestBody); } /** - * Decides whether scrolling on new message is required based on the current viewport - * @param messageList: message list DOM element - * @param lastNElements: number of last elements to consider a live following + * Sets selected language to the target language selector + * @param clickedItem: Language selector element clicked + * @param cid: target conversation id + * @param inputType: type of the language input to apply (incoming or outcoming) */ -function scrollOnNewMessage(messageList, lastNElements = 3) { - // If we see last element of the chat - we are following it - if (getMessageScrollPosition(messageList, lastNElements, MessageScrollPosition.END) === MessageScrollPosition.END) { - messageList.lastChild.scrollIntoView(); - } -} -const importConversationModal = $('#importConversationModal'); -const importConversationOpener = document.getElementById('importConversationOpener'); -const conversationSearchInput = document.getElementById('conversationSearchInput'); -const importConversationModalSuggestions = document.getElementById('importConversationModalSuggestions'); - -const addBySearch = document.getElementById('addBySearch'); - -const newConversationModal = $('#newConversationModal'); -const bindServiceSelect = document.getElementById('bind-service-select') -const addNewConversation = document.getElementById('addNewConversation'); - -const conversationBody = document.getElementById('conversationsBody'); +async function setSelectedLang(clickedItem, cid, inputType = "incoming") { + const selectedLangNode = document.getElementById(`language-selected-${cid}-${inputType}`); + const selectedLangList = document.getElementById(`language-list-${cid}-${inputType}`); -let conversationState = {}; + // console.log('emitted lang update') + const preferredLang = getPreferredLanguage(cid, inputType); + const preferredLangProps = configData['supportedLanguages'][preferredLang]; + const newKey = clickedItem.getAttribute('data-lang'); + const newPreferredLangProps = configData['supportedLanguages'][newKey]; -/** - * Clears conversation state cache - * @param cid - Conversation ID to clear - */ -const clearStateCache = (cid) => { - delete conversationState[cid]; -} -/** - * Sets all participants counters to zero - */ -const setAllCountersToZero = () => { - const countNodes = document.querySelectorAll('[id^="participants-count-"]'); - countNodes.forEach(node => node.innerText = 0); + const direction = inputType === 'incoming' ? 'down' : 'up'; + selectedLangNode.innerHTML = await buildHTMLFromTemplate('selected_lang', { + 'key': newKey, + 'name': newPreferredLangProps['name'], + 'icon': newPreferredLangProps['icon'], + 'direction': direction + }) + if (preferredLangProps) { + selectedLangList.getElementsByClassName('lang-container')[0].insertAdjacentHTML('beforeend', await buildLangOptionHTML(cid, preferredLang, preferredLangProps['name'], preferredLangProps['icon'], inputType)); + } else { + console.warn(`"${preferredLang}" is set to be preferred but currently not supported`) + } + if (clickedItem.parentNode) { + clickedItem.parentNode.removeChild(clickedItem); + } + console.log(`cid=${cid};new preferredLang=${newKey}, inputType=${inputType}`); + await setPreferredLanguage(cid, newKey, inputType, true); + const insertedNode = document.getElementById(getLangOptionID(cid, preferredLang, inputType)); + insertedNode.addEventListener('click', async (e) => { + e.preventDefault(); + await setSelectedLang(insertedNode, cid, inputType); + }); } - /** - * Sets participants count for conversation view - * @param cid - desired conversation id + * Initialize language selector for conversation + * @param cid: target conversation id + * @param inputType: type of the language input to apply (incoming or outcoming) */ -const refreshSubmindsCount = (cid) => { - const participantsCountNode = document.getElementById(`participants-count-${cid}`); - if (participantsCountNode) { - let submindsCount = 0 - if (!isEmpty(submindsState)) { - submindsCount = submindsState["subminds_per_cid"][cid].filter(submind => { - const connectedSubmind = submindsState.connected_subminds[submind.submind_id]; - return connectedSubmind && connectedSubmind.bot_type === "submind" && submind.status === "active"; - }).length; +async function initLanguageSelector(cid, inputType = "incoming") { + let preferredLang = getPreferredLanguage(cid, inputType); + const supportedLanguages = configData['supportedLanguages']; + if (!supportedLanguages.hasOwnProperty(preferredLang)) { + preferredLang = 'en'; + } + const selectedLangNode = document.getElementById(`language-selected-${cid}-${inputType}`); + const langList = document.getElementById(`language-list-${cid}-${inputType}`); + if (langList) { + const langListContainer = langList.getElementsByClassName('lang-container')[0] + + if (langListContainer) { + langListContainer.innerHTML = ""; } - participantsCountNode.innerText = submindsCount; - } -} + // selectedLangNode.innerHTML = ""; + for (const [key, value] of Object.entries(supportedLanguages)) { -/** - * Saves attached files to the server - * @param cid - target conversation id - * @return attachments array or `-1` if something went wrong - */ -const saveAttachedFiles = async (cid) => { - const filesArr = getUploadedFiles(cid); - const attachments = []; - if (filesArr.length > 0) { - setChatState(cid, 'updating', 'Saving attachments...'); - let errorOccurred = null; - const formData = new FormData(); - const attachmentProperties = {} - filesArr.forEach(file => { - const generatedFileName = `${generateUUID(10,'00041000')}.${file.name.split('.').pop()}`; - attachmentProperties[generatedFileName] = { - 'size': file.size, - 'type': file.type + if (key === preferredLang) { + const direction = inputType === 'incoming' ? 'down' : 'up'; + selectedLangNode.innerHTML = await buildHTMLFromTemplate('selected_lang', { + 'key': key, + 'name': value['name'], + 'icon': value['icon'], + 'direction': direction + }) + } else { + langListContainer.insertAdjacentHTML('beforeend', await buildLangOptionHTML(cid, key, value['name'], value['icon'], inputType)); + const itemNode = document.getElementById(getLangOptionID(cid, key, inputType)); + itemNode.addEventListener('click', async (e) => { + e.preventDefault(); + await setSelectedLang(itemNode, cid, inputType) + }); } - const renamedFile = new File([file], generatedFileName, { - type: file.type - }); - formData.append('files', renamedFile); - }); - cleanUploadedFiles(cid); - - await fetchServer(`files/attachments`, REQUEST_METHODS.POST, formData) - .then(async response => { - const responseJson = await response.json(); - if (response.ok) { - for (const [fileName, savedName] of Object.entries(responseJson['location_mapping'])) { - attachments.push({ - 'name': savedName, - 'size': attachmentProperties[fileName].size, - 'mime': attachmentProperties[fileName].type - }) - } - } else { - throw `Failed to save attachments status=${response.status}, msg=${responseJson}`; - } - }).catch(err => { - errorOccurred = err; - }); - setChatState(cid, 'active') - if (errorOccurred) { - console.error(`Error during attachments preparation: ${errorOccurred}, skipping message sending`); - return -1 - } else { - console.log('Received attachments array: ', attachments); } } - return attachments; } /** - * Supported conversation skins - * @type Object + * Inits both incoming and outcoming language selectors + * @param cid: target conversation id */ -const CONVERSATION_SKINS = { - BASE: 'base', - PROMPTS: 'prompts' +const initLanguageSelectors = async (cid) => { + for (const inputType of ['incoming', 'outcoming']) { + await initLanguageSelector(cid, inputType); + } } -/** - * Initiates selection of the table rows. - * @param table - target table to select - * @param exportToExcelBtn - DOM element of `Export to Excel` button - */ -const startSelection = (table, exportToExcelBtn) => { - table.classList.remove('selected'); - const container = table.parentElement.parentElement; - if (Array.from(container.getElementsByClassName('selected')).length === 0) { - exportToExcelBtn.disabled = true; - } - startTimer(); + +function getMessageReferType(inputType) { + return inputType === 'incoming' ? MESSAGE_REFER_TYPE.OTHERS : MESSAGE_REFER_TYPE.MINE; } /** - * Marks target table as selected - * @param table - HTMLTable element - * @param exportToExcelBtn - export to excel button (optional) + * Sends request to server for chat language refreshing */ -const selectTable = (table, exportToExcelBtn = null) => { - const timePassed = stopTimer(); - if (timePassed >= 300) { - if (exportToExcelBtn) - exportToExcelBtn.disabled = false; - table.classList.add('selected'); +async function requestChatsLanguageRefresh() { + const languageMapping = currentUser?.preferences?.chat_language_mapping || {}; + console.log(`languageMapping=${JSON.stringify(languageMapping)}`) + for (const [cid, value] of Object.entries(languageMapping)) { + if (isDisplayed(cid)) { + for (const inputType of ['incoming', 'outcoming']) { + const lang = value[inputType] || 'en'; + if (lang !== 'en') { + await setPreferredLanguage(cid, lang, inputType, false); + } + } + } } + console.log(`chatLanguageMapping=${JSON.stringify(getChatLanguageMapping())}`) } /** - * Wraps the provided array of HTMLTable elements into XLSX file and exports it to the invoked user - * @param tables - array of HTMLTable elements to export - * @param filePrefix - prefix of the file name to be imported - * @param sheetPrefix - prefix to apply for each sheet generated per HTMLTable - * @param appname - name of the application to export (defaults to Excel) + * Applies translation based on received data + * @param data: translation object received + * Note: data should be of format: + * { + * 'cid': {'message1':'translation of message 1', + * 'message2':'translation of message 2'} + * } */ -const exportTablesToExcel = (function() { - const uri = 'data:application/vnd.ms-excel;base64,'; - const tmplWorkbookXML = ` - - - - -PyKlatchat Generator -{created} - - -' -' - -{worksheets} - -` - const tmplWorksheetXML = '{rows}
' - const tmplCellXML = '{data}' - const base64 = function(s) { - return window.btoa(unescape(encodeURIComponent(s))) - } - const format = function(s, c) { - return s.replace(/{(\w+)}/g, function(m, p) { - return c[p]; - }) - } - return function(tables, filePrefix, sheetPrefix = '', appname = 'Excel') { - let ctx = ""; - let workbookXML = ""; - let worksheetsXML = ""; - let rowsXML = ""; +async function applyTranslations(data) { + const inputType = setDefault(data, 'input_type', 'incoming'); + for (const [cid, messageTranslations] of Object.entries(data['translations'])) { - for (let i = 0; i < tables.length; i++) { - if (!tables[i].nodeType) tables[i] = document.getElementById(tables[i]); - for (let j = 0; j < tables[i].rows.length; j++) { - rowsXML += '' - for (let k = 0; k < tables[i].rows[j].cells.length; k++) { - let data = tables[i].rows[j].cells[k].innerHTML - if (k === 0) { - const chatImgElem = tables[i].rows[j].cells[k].getElementsByClassName("chat-img")[0] - if (chatImgElem) { - data = chatImgElem.getAttribute("title"); - } - } - ctx = { - data: data, - }; - rowsXML += format(tmplCellXML, ctx); - } - rowsXML += '' - } - const sheetName = sheetPrefix.replaceAll("{id}", tables[i].id); - ctx = { - rows: rowsXML, - nameWS: sheetName || 'Sheet' + i - }; - worksheetsXML += format(tmplWorksheetXML, ctx); - rowsXML = ""; + if (!isDisplayed(cid)) { + console.log(`cid=${cid} is not displayed, skipping translations population`) + continue; } - ctx = { - created: getCurrentTimestamp() * 1000, - worksheets: worksheetsXML - }; - workbookXML = format(tmplWorkbookXML, ctx); + setChatState(cid, 'active'); - let link = document.createElement("A"); - link.href = uri + base64(workbookXML); - const fileName = `${filePrefix}_${getCurrentTimestamp()}`; - link.download = `${fileName}.xls`; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + console.debug(`Fetching translation of ${cid}`); + // console.debug(`translations=${JSON.stringify(messageTranslations)}`) + + const messageTranslationsShouts = messageTranslations['shouts']; + if (messageTranslationsShouts) { + const messageReferType = getMessageReferType(inputType); + const messages = getMessagesOfCID(cid, messageReferType, 'plain'); + Array.from(messages).forEach(message => { + const messageID = message.id; + let repliedMessage = null; + let repliedMessageID = null; + try { + repliedMessage = message.getElementsByClassName('reply-placeholder')[0].getElementsByClassName('reply-text')[0]; + repliedMessageID = repliedMessage.getAttribute('data-replied-id') + // console.debug(`repliedMessageID=${repliedMessageID}`) + } catch (e) { + // console.debug(`replied message not found for ${messageID}`); + } + if (messageID in messageTranslationsShouts) { + message.getElementsByClassName('message-text')[0].innerHTML = messageTranslationsShouts[messageID]; + } + if (repliedMessageID && repliedMessageID in messageTranslationsShouts) { + repliedMessage.innerHTML = messageTranslationsShouts[repliedMessageID]; + } + }); + await initLanguageSelector(cid, inputType); + } } -})(); +} -/** - * Sends the message based on input - * @param inputElem - input DOM element - * @param cid - target conversation id - * @param repliedMessageId - replied message id (optional) - * @param isAudio - `1` if the message is audio-message (defaults to `0`) - * @param isAnnouncement - `1` if the message is an announcement (defaults to `0`) - */ -const sendMessage = async (inputElem, cid, repliedMessageId = null, isAudio = '0', isAnnouncement = '0') => { - const attachments = await saveAttachedFiles(cid); - if (Array.isArray(attachments)) { - emitUserMessage(inputElem, cid, repliedMessageId, attachments, isAudio, isAnnouncement); +const getChatLanguageMapping = (cid = null, inputType = null) => { + let res = setDefault(setDefault(currentUser, 'preferences', {}), 'chat_language_mapping', {}); + if (cid) { + res = setDefault(res, cid, {}); + } + if (inputType) { + res = setDefault(res, inputType, 'en'); } - inputElem.value = ""; + return res; +} + +const updateChatLanguageMapping = (cid, inputType, lang) => { + setDefault(currentUser.preferences.chat_language_mapping, cid, {})[inputType] = lang; + console.log(`cid=${cid},inputType=${inputType} updated to lang=${lang}`); } /** - * Gets all opened chat ids - * @return {[]} list of displayed chat ids + * Custom Event fired on supported languages init + * @type {CustomEvent} */ -function getOpenedChatIds() { - let cids = []; - Array.from(conversationBody.getElementsByClassName('conversationContainer')).forEach(conversationContainer => { - cids.push(conversationContainer.getElementsByClassName('card')[0].id); +const supportedLanguagesLoadedEvent = new CustomEvent("supportedLanguagesLoaded", { + "detail": "Event that is fired when system supported languages are loaded" +}); + +document.addEventListener('DOMContentLoaded', (_) => { + document.addEventListener('configLoaded', async (_) => { + await fetchSupportedLanguages().then(_ => document.dispatchEvent(supportedLanguagesLoadedEvent)); }); - return cids; -} +}); +document.addEventListener('configLoaded', async (_) => { -const resizeConversationContainers = () => { - const openedChatIds = getOpenedChatIds(); - const newWidth = `${100/openedChatIds.length}vw`; - openedChatIds.forEach(cid => { - document.getElementById(cid).style.width = newWidth; - }) + const buildVersion = configData?.["BUILD_VERSION"]; + const buildTS = configData?.["BUILD_TS"]; + if (buildVersion && buildTS) { + document.getElementById("app-version").innerText = `v${buildVersion} (${getTimeFromTimestamp(buildTS)})`; + } +}); +const DATABASES = { + CHATS: 'chats' +} +const DB_TABLES = { + CHAT_ALIGNMENT: 'chat_alignment', + MINIFY_SETTINGS: 'minify_settings', + CHAT_MESSAGES_PAGINATION: 'chat_messages_pagination' +} +const __db_instances = {} +const __db_definitions = { + [DATABASES.CHATS]: { + [DB_TABLES.CHAT_ALIGNMENT]: `cid, added_on, skin`, + [DB_TABLES.CHAT_MESSAGES_PAGINATION]: `cid, oldest_created_on` + } } /** - * Builds new conversation HTML from provided data and attaches it to the list of displayed conversations - * @param conversationData - JS Object containing conversation data of type: - * { - * '_id': 'id of conversation', - * 'conversation_name': 'title of the conversation', - * 'chat_flow': [{ - * 'user_nickname': 'nickname of sender', - * 'user_avatar': 'avatar of sender', - * 'message_id': 'id of the message', - * 'message_text': 'text of the message', - * 'is_audio': true if message is an audio message - * 'is_announcement': true if message is considered to be an announcement - * 'created_on': 'creation time of the message' - * }, ... (num of user messages returned)] - * } - * @param skin - Conversation skin to build - * @param remember - to store this conversation into localStorage (defaults to true)* - * @param conversationParentID - ID of conversation parent - * @return id of the built conversation + * Gets database and table from name + * @param db: database name to get + * @param table: table name to get + * @return {Table} Dexie database object under specified table */ -async function buildConversation(conversationData, skin, remember = true, conversationParentID = 'conversationsBody') { - const idField = '_id'; - const cid = conversationData[idField]; - if (!cid) { - console.error(`Failed to extract id field="${idField}" from conversation data - ${conversationData}`); - return -1; - } - if (remember) { - await addNewCID(cid, skin); +const getDb = (db, table) => { + let _instance; + if (!Object.keys(__db_instances).includes(db)) { + _instance = new Dexie(name); + if (Object.keys(__db_definitions).includes(db)) { + _instance.version(1).stores(__db_definitions[db]); + } + __db_instances[db] = _instance; + } else { + _instance = __db_instances[db]; } - const newConversationHTML = await buildConversationHTML(conversationData, skin); - const conversationsBody = document.getElementById(conversationParentID); - conversationsBody.insertAdjacentHTML('afterbegin', newConversationHTML); + return _instance[table]; +} - resizeConversationContainers() - setChatState(cid, CHAT_STATES.UPDATING, "Loading messages...") - initMessages(conversationData, skin).then(_ => setChatState(cid, CHAT_STATES.ACTIVE)); +class DBGateway { + constructor(db, table) { + this.db = db; + this.table = table; - const messageListContainer = getMessageListContainer(cid); - const currentConversation = document.getElementById(cid); - const conversationParent = currentConversation.parentElement; - const conversationHolder = conversationParent.parentElement; + this._db_instance = getDb(this.db, this.table); + this._db_columns_definitions = __db_definitions[this.db][this.table] + this._db_key = this._db_columns_definitions.split(',')[0] + } - let chatCloseButton = document.getElementById(`close-${cid}`); - const chatInputButton = document.getElementById(conversationData['_id'] + '-send'); - const filenamesContainer = document.getElementById(`filename-container-${conversationData['_id']}`) - const attachmentsButton = document.getElementById('file-input-' + conversationData['_id']); - const textInputElem = document.getElementById(conversationData['_id'] + '-input'); - if (chatInputButton.hasAttribute('data-target-cid')) { - textInputElem.addEventListener('keyup', async (e) => { - if (e.shiftKey && e.key === 'Enter') { - await sendMessage(textInputElem, conversationData['_id']); - } - }); - chatInputButton.addEventListener('click', async (e) => { - await sendMessage(textInputElem, conversationData['_id']); - }); + async getItem(key = "") { + return await this._db_instance.where({ + [this._db_key]: key + }).first(); } - attachmentsButton.addEventListener('change', (e) => { - e.preventDefault(); - const fileName = getFilenameFromPath(e.currentTarget.value); - const lastFile = attachmentsButton.files[attachmentsButton.files.length - 1] - if (lastFile.size > configData['maxUploadSize']) { - console.warn(`Uploaded file is too big`); - } else { - addUpload(attachmentsButton.parentNode.parentNode.id, lastFile); - filenamesContainer.insertAdjacentHTML('afterbegin', - `${fileName}`); - filenamesContainer.style.display = ""; - if (filenamesContainer.children.length === configData['maxNumAttachments']) { - attachmentsButton.disabled = true; - } + async listItems(orderBy = "") { + let expression = this._db_instance; + if (orderBy !== "") { + expression = expression.orderBy(orderBy) } - }); - await addRecorder(conversationData); - await initLanguageSelectors(conversationData['_id']); - - if (skin === CONVERSATION_SKINS.BASE) { - const promptModeButton = document.getElementById(`prompt-mode-${conversationData['_id']}`); - - promptModeButton.addEventListener('click', async (e) => { - e.preventDefault(); - chatCloseButton.click(); - await displayConversation(conversationData['_id'], CONVERSATION_SKINS.PROMPTS, null, conversationParentID); - }); - } else if (skin === CONVERSATION_SKINS.PROMPTS) { - chatCloseButton = document.getElementById(`close-prompts-${cid}`); - const baseModeButton = document.getElementById(`base-mode-${cid}`); - const exportToExcelBtn = document.getElementById(`${cid}-export-to-excel`) - - // TODO: fix here to use prompt- prefix - baseModeButton.addEventListener('click', async (e) => { - e.preventDefault(); - chatCloseButton.click(); - await displayConversation(cid, CONVERSATION_SKINS.BASE, null, conversationParentID); - }); - - // TODO: make an array of prompt tables only in dedicated conversation - Array.from(getMessagesOfCID(cid, MESSAGE_REFER_TYPE.ALL, 'prompt', false)).forEach(table => { - - table.addEventListener('mousedown', (_) => startSelection(table, exportToExcelBtn)); - table.addEventListener('touchstart', (_) => startSelection(table, exportToExcelBtn)); - table.addEventListener('mouseup', (_) => selectTable(table, exportToExcelBtn)); - table.addEventListener("touchend", (_) => selectTable(table, exportToExcelBtn)); - - }); - exportToExcelBtn.addEventListener('click', (e) => { - const selectedTables = messageListContainer.getElementsByClassName('selected'); - exportTablesToExcel(selectedTables, `prompts_of_${cid}`, 'prompt_{id}'); - Array.from(selectedTables).forEach(selectedTable => { - selectedTable.classList.remove('selected'); - }); - }); + return await expression.toArray(); } - if (chatCloseButton.hasAttribute('data-target-cid')) { - chatCloseButton.addEventListener('click', async (_) => { - conversationHolder.removeChild(conversationParent); - await removeConversation(cid); - clearStateCache(cid); - resizeConversationContainers() - }); + async putItem(data = {}) { + return await this._db_instance.put(data, [data[this._db_key]]) } - // Hide close button for Nano Frames - if (configData.client === CLIENTS.NANO) { - chatCloseButton.hidden = true; + + updateItem(data = {}) { + const key = data[this._db_key] + delete data[this._db_key] + return this._db_instance.update(key, data); } - setTimeout(() => getMessageListContainer(conversationData['_id']).lastElementChild?.scrollIntoView(true), 0); - setTimeout(() => document.getElementById('klatchatHeader').scrollIntoView(true), 0); - // $('#copyrightContainer').css('position', 'inherit'); - return cid; -} -/** - * Gets conversation data based on input string - * @param input - input string text - * @param oldestMessageTS - creation timestamp of the oldest displayed message - * @param skin - resolves by server for which data to return - * @param maxResults - max number of messages to fetch - * @returns {Promise<{}>} promise resolving conversation data returned - */ -async function getConversationDataByInput(input, skin, oldestMessageTS = null, maxResults = 10) { - let conversationData = {}; - if (input) { - let query_url = `chat_api/search/${input.toString()}?limit_chat_history=${maxResults}&skin=${skin}`; - if (oldestMessageTS) { - query_url += `&creation_time_from=${oldestMessageTS}`; - } - await fetchServer(query_url) - .then(response => { - if (response.ok) { - return response.json(); - } else { - throw response.statusText; - } - }) - .then(data => { - if (getUserMessages(data, null).length === 0) { - console.log('All of the messages are already displayed'); - setDefault(setDefault(conversationState, data['_id'], {}), 'all_messages_displayed', true); - } - conversationData = data; - }).catch(async err => { - console.warn('Failed to fulfill request due to error:', err); - }); + async deleteItem(key = "") { + return await this._db_instance.where({ + [this._db_key]: key + }).delete(); } - return conversationData; -} -/** - * Returns table representing chat alignment - * @return {Table} - */ -const getChatAlignmentTable = () => { - return getDb(DATABASES.CHATS, DB_TABLES.CHAT_ALIGNMENT); + static getInstance(table) { + return new DBGateway(DATABASES.CHATS, table); + } } - /** - * Retrieves conversation layout from local storage - * @returns {Array} collection of database-stored elements + * Displays modal bounded to the provided conversation id + * @param modalElem: modal to display + * @param cid: conversation id to consider */ -async function retrieveItemsLayout(idOnly = false) { - let layout = await getChatAlignmentTable().orderBy("added_on").toArray(); - if (idOnly) { - layout = layout.map(a => a.cid); - } - return layout; +function displayModalInCID(modalElem, cid) { + modalElem.modal('hide'); + $('.modal-backdrop').appendTo(`#${cid}`); + modalElem.modal('show'); } +const importConversationModal = $('#importConversationModal'); +const importConversationOpener = document.getElementById('importConversationOpener'); +const conversationSearchInput = document.getElementById('conversationSearchInput'); +const importConversationModalSuggestions = document.getElementById('importConversationModalSuggestions'); + +const addBySearch = document.getElementById('addBySearch'); + +const newConversationModal = $('#newConversationModal'); +const bindServiceSelect = document.getElementById('bind-service-select') +const addNewConversation = document.getElementById('addNewConversation'); + +const conversationBody = document.getElementById('conversationsBody'); +let conversationState = {}; /** - * Adds new conversation id to local storage - * @param cid - conversation id to add - * @param skin - conversation skin to add + * Clears conversation state cache + * @param cid - Conversation ID to clear */ -async function addNewCID(cid, skin) { - return await getChatAlignmentTable().put({ - 'cid': cid, - 'skin': skin, - 'added_on': getCurrentTimestamp() - }, [cid]); +const clearStateCache = (cid) => { + delete conversationState[cid]; } - /** - * Removed conversation id from local storage - * @param cid - conversation id to remove + * Sets all participants counters to zero */ -async function removeConversation(cid) { - return await Promise.all([DBGateway.getInstance(DB_TABLES.CHAT_ALIGNMENT).deleteItem(cid), - DBGateway.getInstance(DB_TABLES.CHAT_MESSAGES_PAGINATION).deleteItem(cid) - ]); +const setAllCountersToZero = () => { + const countNodes = document.querySelectorAll('[id^="participants-count-"]'); + countNodes.forEach(node => node.innerText = 0); } + /** - * Checks if conversation is displayed - * @param cid - target conversation id - * - * @return true if cid is stored in client db, false otherwise + * Sets participants count for conversation view + * @param cid - desired conversation id */ -function isDisplayed(cid) { - return document.getElementById(cid) !== null; +const refreshSubmindsCount = (cid) => { + const participantsCountNode = document.getElementById(`participants-count-${cid}`); + if (participantsCountNode) { + let submindsCount = 0 + if (!isEmpty(submindsState)) { + submindsCount = submindsState["subminds_per_cid"][cid].filter(submind => { + const connectedSubmind = submindsState.connected_subminds[submind.submind_id]; + return connectedSubmind && connectedSubmind.bot_type === "submind" && submind.status === "active"; + }).length; + } + participantsCountNode.innerText = submindsCount; + } } /** - * Gets value of desired property in stored conversation + * Saves attached files to the server * @param cid - target conversation id - * - * @return true if cid is displayed, false otherwise + * @return attachments array or `-1` if something went wrong */ -async function getStoredConversationData(cid) { - return await getChatAlignmentTable().where({ - cid: cid - }).first(); +const saveAttachedFiles = async (cid) => { + const filesArr = getUploadedFiles(cid); + const attachments = []; + if (filesArr.length > 0) { + setChatState(cid, 'updating', 'Saving attachments...'); + let errorOccurred = null; + const formData = new FormData(); + const attachmentProperties = {} + filesArr.forEach(file => { + const generatedFileName = `${generateUUID(10,'00041000')}.${file.name.split('.').pop()}`; + attachmentProperties[generatedFileName] = { + 'size': file.size, + 'type': file.type + } + const renamedFile = new File([file], generatedFileName, { + type: file.type + }); + formData.append('files', renamedFile); + }); + cleanUploadedFiles(cid); + + await fetchServer(`files/attachments`, REQUEST_METHODS.POST, formData) + .then(async response => { + const responseJson = await response.json(); + if (response.ok) { + for (const [fileName, savedName] of Object.entries(responseJson['location_mapping'])) { + attachments.push({ + 'name': savedName, + 'size': attachmentProperties[fileName].size, + 'mime': attachmentProperties[fileName].type + }) + } + } else { + throw `Failed to save attachments status=${response.status}, msg=${responseJson}`; + } + }).catch(err => { + errorOccurred = err; + }); + setChatState(cid, 'active') + if (errorOccurred) { + console.error(`Error during attachments preparation: ${errorOccurred}, skipping message sending`); + return -1 + } else { + console.log('Received attachments array: ', attachments); + } + } + return attachments; } /** - * Returns current skin of provided conversation id - * @param cid - target conversation id - * - * @return {string} skin from CONVERSATION_SKINS + * Supported conversation skins + * @type Object */ -async function getCurrentSkin(cid) { - const storedCID = await getStoredConversationData(cid); - if (storedCID) { - return storedCID['skin']; - } - return null; +const CONVERSATION_SKINS = { + BASE: 'base', + PROMPTS: 'prompts' } /** - * Boolean function that checks whether live chats must be displayed based on page meta properties - * @returns {boolean} true if live chat should be displayed, false otherwise + * Initiates selection of the table rows. + * @param table - target table to select + * @param exportToExcelBtn - DOM element of `Export to Excel` button */ -const shouldDisplayLiveChat = () => { - const liveMetaElem = document.querySelector("meta[name='live']"); - if (liveMetaElem) { - return liveMetaElem.getAttribute("content") === "1" +const startSelection = (table, exportToExcelBtn) => { + table.classList.remove('selected'); + const container = table.parentElement.parentElement; + if (Array.from(container.getElementsByClassName('selected')).length === 0) { + exportToExcelBtn.disabled = true; } - return false + startTimer(); } + /** - * Fetches latest live conversation from the klat server API and builds its HTML - * @returns {Promise<*>} fetched conversation data + * Marks target table as selected + * @param table - HTMLTable element + * @param exportToExcelBtn - export to excel button (optional) */ -const displayLiveChat = async () => { - return await fetchServer('chat_api/live') - .then(response => { - if (response.ok) { - return response.json(); - } else { - throw response.statusText; - } - }) - .then(data => { - if (getUserMessages(data, null).length === 0) { - console.debug('All of the messages are already displayed'); - setDefault(setDefault(conversationState, data['_id'], {}), 'all_messages_displayed', true); - } - return data; - }) - .then( - async data => { - await buildConversation(data, CONVERSATION_SKINS.PROMPTS, true); - return data; - } - ) - .catch(async err => { - console.warn('Failed to display live chat:', err); - }); +const selectTable = (table, exportToExcelBtn = null) => { + const timePassed = stopTimer(); + if (timePassed >= 300) { + if (exportToExcelBtn) + exportToExcelBtn.disabled = false; + table.classList.add('selected'); + } } /** - * Restores chat alignment based on the page cache + * Wraps the provided array of HTMLTable elements into XLSX file and exports it to the invoked user + * @param tables - array of HTMLTable elements to export + * @param filePrefix - prefix of the file name to be imported + * @param sheetPrefix - prefix to apply for each sheet generated per HTMLTable + * @param appname - name of the application to export (defaults to Excel) */ -const restoreChatAlignmentFromCache = async () => { - let cachedItems = await retrieveItemsLayout(); - if (cachedItems.length === 0) { - await displayLiveChat(); +const exportTablesToExcel = (function() { + const uri = 'data:application/vnd.ms-excel;base64,'; + const tmplWorkbookXML = ` + + + + +PyKlatchat Generator +{created} + + +' +' + +{worksheets} + +` + const tmplWorksheetXML = '{rows}
' + const tmplCellXML = '{data}' + const base64 = function(s) { + return window.btoa(unescape(encodeURIComponent(s))) } - for (const item of cachedItems) { - await getConversationDataByInput(item.cid, item.skin).then(async conversationData => { - if (conversationData && Object.keys(conversationData).length > 0) { - await buildConversation(conversationData, item.skin, false); - } else { - if (item.cid !== '1') { - displayAlert(document.getElementById('conversationsBody'), 'No matching conversation found', 'danger', 'noRestoreConversationAlert', { - 'type': alertBehaviors.AUTO_EXPIRE - }); + const format = function(s, c) { + return s.replace(/{(\w+)}/g, function(m, p) { + return c[p]; + }) + } + return function(tables, filePrefix, sheetPrefix = '', appname = 'Excel') { + let ctx = ""; + let workbookXML = ""; + let worksheetsXML = ""; + let rowsXML = ""; + + for (let i = 0; i < tables.length; i++) { + if (!tables[i].nodeType) tables[i] = document.getElementById(tables[i]); + for (let j = 0; j < tables[i].rows.length; j++) { + rowsXML += '' + for (let k = 0; k < tables[i].rows[j].cells.length; k++) { + let data = tables[i].rows[j].cells[k].innerHTML + if (k === 0) { + const chatImgElem = tables[i].rows[j].cells[k].getElementsByClassName("chat-img")[0] + if (chatImgElem) { + data = chatImgElem.getAttribute("title"); + } + } + ctx = { + data: data, + }; + rowsXML += format(tmplCellXML, ctx); } - await removeConversation(item.cid); + rowsXML += '' } - }); - } -} + const sheetName = sheetPrefix.replaceAll("{id}", tables[i].id); + ctx = { + rows: rowsXML, + nameWS: sheetName || 'Sheet' + i + }; + worksheetsXML += format(tmplWorksheetXML, ctx); + rowsXML = ""; + } -/** - * Custom Event fired on supported languages init - * @type {CustomEvent} - */ -const chatAlignmentRestoredEvent = new CustomEvent("chatAlignmentRestored", { - "detail": "Event that is fired when chat alignment is restored" -}); + ctx = { + created: getCurrentTimestamp() * 1000, + worksheets: worksheetsXML + }; + workbookXML = format(tmplWorkbookXML, ctx); -/** - * Restores chats alignment from the local storage - **/ -async function restoreChatAlignment() { - if (shouldDisplayLiveChat()) { - await displayLiveChat(); - } else { - await restoreChatAlignmentFromCache(); + let link = document.createElement("A"); + link.href = uri + base64(workbookXML); + const fileName = `${filePrefix}_${getCurrentTimestamp()}`; + link.download = `${fileName}.xls`; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); } - console.debug('Chat Alignment Restored'); - document.dispatchEvent(chatAlignmentRestoredEvent); -} - +})(); -/** - * Helper struct to decide on which kind of messages to refer - * "all" - all the messages - * "mine" - only the messages emitted by current user - * "others" - all the messages except "mine" - */ -const MESSAGE_REFER_TYPE = { - ALL: 'all', - MINE: 'mine', - OTHERS: 'other' -} /** - * Gets array of messages for provided conversation id + * Sends the message based on input + * @param inputElem - input DOM element * @param cid - target conversation id - * @param messageReferType - message refer type to consider from `MESSAGE_REFER_TYPE` - * @param idOnly - to return id only (defaults to false) - * @param forceType - to get only the certain type of messages (optional) - * - * @return array of message DOM objects under given conversation + * @param repliedMessageId - replied message id (optional) + * @param isAudio - `1` if the message is audio-message (defaults to `0`) + * @param isAnnouncement - `1` if the message is an announcement (defaults to `0`) */ -function getMessagesOfCID(cid, messageReferType = MESSAGE_REFER_TYPE.ALL, forceType = null, idOnly = false) { - let messages = [] - const messageContainer = getMessageListContainer(cid); - if (messageContainer) { - const listItems = messageContainer.getElementsByTagName('li'); - Array.from(listItems).forEach(li => { - try { - const messageNode = getMessageNode(li, forceType); - if (messageNode) { - if (messageReferType === MESSAGE_REFER_TYPE.ALL || - (messageReferType === MESSAGE_REFER_TYPE.MINE && messageNode.getAttribute('data-sender') === currentUser['nickname']) || - (messageReferType === MESSAGE_REFER_TYPE.OTHERS && messageNode.getAttribute('data-sender') !== currentUser['nickname'])) { - if (idOnly) { - messages.push(messageNode.id); - } else { - messages.push(messageNode); - } - } - } - } catch (e) { - console.warn(`Failed to get message under node: ${li} - ${e}`); - } - }); +const sendMessage = async (inputElem, cid, repliedMessageId = null, isAudio = '0', isAnnouncement = '0') => { + const attachments = await saveAttachedFiles(cid); + if (Array.isArray(attachments)) { + emitUserMessage(inputElem, cid, repliedMessageId, attachments, isAudio, isAnnouncement); } - return messages; + inputElem.value = ""; } /** - * Refreshes chat view (for instance when user session gets updated) + * Gets all opened chat ids + * @return {[]} list of displayed chat ids */ -function refreshChatView(conversationContainer = null) { - if (!conversationContainer) { - conversationContainer = conversationBody; - } - Array.from(conversationContainer.getElementsByClassName('conversationContainer')).forEach(async conversation => { - const cid = conversation.getElementsByClassName('card')[0].id; - const skin = await getCurrentSkin(cid); - if (skin === CONVERSATION_SKINS.BASE) { - const messages = getMessagesOfCID(cid, MESSAGE_REFER_TYPE.ALL, 'plain'); - Array.from(messages).forEach(message => { - if (message.hasAttribute('data-sender')) { - const messageSenderNickname = message.getAttribute('data-sender'); - if (message.parentElement.parentElement.className !== 'announcement') - message.parentElement.parentElement.className = (currentUser && messageSenderNickname === currentUser['nickname']) ? 'in' : 'out'; - } - }); - } - await initLanguageSelectors(cid); +function getOpenedChatIds() { + let cids = []; + Array.from(conversationBody.getElementsByClassName('conversationContainer')).forEach(conversationContainer => { + cids.push(conversationContainer.getElementsByClassName('card')[0].id); }); + return cids; } -/** - * Enum of possible displayed chat states - * "active" - ready to be used by user - * "updating" - in processes of applying changes, temporary unavailable - */ -const CHAT_STATES = { - ACTIVE: 'active', - UPDATING: 'updating', +const resizeConversationContainers = () => { + const openedChatIds = getOpenedChatIds(); + const newWidth = `${100/openedChatIds.length}vw`; + openedChatIds.forEach(cid => { + document.getElementById(cid).style.width = newWidth; + }) } /** - * Sets state to the desired cid - * @param cid - target conversation id - * @param state - the new chat state from `CHAT_STATES` - * @param state_msg - reason for state transitioning (optional) + * Builds new conversation HTML from provided data and attaches it to the list of displayed conversations + * @param conversationData - JS Object containing conversation data of type: + * { + * '_id': 'id of conversation', + * 'conversation_name': 'title of the conversation', + * 'chat_flow': [{ + * 'user_nickname': 'nickname of sender', + * 'user_avatar': 'avatar of sender', + * 'message_id': 'id of the message', + * 'message_text': 'text of the message', + * 'is_audio': true if message is an audio message + * 'is_announcement': true if message is considered to be an announcement + * 'created_on': 'creation time of the message' + * }, ... (num of user messages returned)] + * } + * @param skin - Conversation skin to build + * @param remember - to store this conversation into localStorage (defaults to true)* + * @param conversationParentID - ID of conversation parent + * @return id of the built conversation */ -function setChatState(cid, state = CHAT_STATES.ACTIVE, state_msg = '') { - // TODO: refactor this method to handle when there are multiple messages on a stack - // console.log(`cid=${cid}, state=${state}, state_msg=${state_msg}`) - const cidNode = document.getElementById(cid); - if (cidNode) { - setDefault(setDefault(conversationState, cid, {})) - const spinner = document.getElementById(`${cid}-spinner`); - const spinnerUpdateMsg = document.getElementById(`${cid}-update-msg`); - if (state === 'updating') { - cidNode.classList.add('chat-loading'); - spinner.style.setProperty('display', 'flex', 'important'); - spinnerUpdateMsg.innerHTML = state_msg; - } else if (state === 'active') { - cidNode.classList.remove('chat-loading'); - spinner.style.setProperty('display', 'none', 'important'); - spinnerUpdateMsg.innerHTML = ''; - } - conversationState[cid]['state'] = state; - conversationState[cid]['state_message'] = state_msg; +async function buildConversation(conversationData, skin, remember = true, conversationParentID = 'conversationsBody') { + const idField = '_id'; + const cid = conversationData[idField]; + if (!cid) { + console.error(`Failed to extract id field="${idField}" from conversation data - ${conversationData}`); + return -1; } -} + if (remember) { + await addNewCID(cid, skin); + } + const newConversationHTML = await buildConversationHTML(conversationData, skin); + const conversationsBody = document.getElementById(conversationParentID); + conversationsBody.insertAdjacentHTML('afterbegin', newConversationHTML); -/** - * Displays first conversation matching search string - * @param searchStr - Search string to find matching conversation - * @param skin - target conversation skin to display - * @param alertParentID - id of the element to display alert in - * @param conversationParentID - parent Node ID of the conversation - */ -async function displayConversation(searchStr, skin = CONVERSATION_SKINS.PROMPTS, alertParentID = null, conversationParentID = 'conversationsBody') { - if (getOpenedChatIds().length === configData.MAX_CONVERSATIONS_PER_PAGE) { - alert(`Up to ${configData.MAX_CONVERSATIONS_PER_PAGE} allowed per page`) - } else if (searchStr !== "") { - const alertParent = document.getElementById(alertParentID || conversationParentID); - await getConversationDataByInput(searchStr, skin, null, 10).then(async conversationData => { - let responseOk = false; - if (!conversationData || Object.keys(conversationData).length === 0) { - displayAlert( - alertParent, - 'Cannot find conversation matching your search', - 'danger', - 'noSuchConversationAlert', { - 'type': alertBehaviors.AUTO_EXPIRE - } - ); - } else if (isDisplayed(conversationData['_id'])) { - displayAlert(alertParent, 'Chat is already displayed', 'danger'); - } else { - await buildConversation(conversationData, skin, true, conversationParentID); - if (skin === CONVERSATION_SKINS.BASE) { - for (const inputType of ['incoming', 'outcoming']) { - await requestTranslation(conversationData['_id'], null, null, inputType); - } - } - responseOk = true; - if (configData.client === CLIENTS.NANO) { - attachEditModalInvoker(document.getElementById(`${conversationData['_id']}-account-link`)); - updateNavbar(); - initSettings(document.getElementById(`${conversationData['_id']}-settings-link`)); - } + resizeConversationContainers() + + setChatState(cid, CHAT_STATES.UPDATING, "Loading messages...") + initMessages(conversationData, skin).then(_ => setChatState(cid, CHAT_STATES.ACTIVE)); + + const messageListContainer = getMessageListContainer(cid); + const currentConversation = document.getElementById(cid); + const conversationParent = currentConversation.parentElement; + const conversationHolder = conversationParent.parentElement; + + let chatCloseButton = document.getElementById(`close-${cid}`); + const chatInputButton = document.getElementById(conversationData['_id'] + '-send'); + const filenamesContainer = document.getElementById(`filename-container-${conversationData['_id']}`) + const attachmentsButton = document.getElementById('file-input-' + conversationData['_id']); + const textInputElem = document.getElementById(conversationData['_id'] + '-input'); + if (chatInputButton.hasAttribute('data-target-cid')) { + textInputElem.addEventListener('keyup', async (e) => { + if (e.shiftKey && e.key === 'Enter') { + await sendMessage(textInputElem, conversationData['_id']); } - return responseOk; + }); + chatInputButton.addEventListener('click', async (e) => { + await sendMessage(textInputElem, conversationData['_id']); }); } -} - -/** - * Handles requests on creation new conversation by the user - * @param conversationName - New Conversation Name - * @param isPrivate - if conversation should be private (defaults to false) - * @param boundServiceID - id of the service to bind to conversation (optional) - * @param createLiveConversation - if conversation should be treated as live conversation (defaults to false) - */ -async function createNewConversation(conversationName, isPrivate = false, boundServiceID = null, createLiveConversation = false) { - - let formData = new FormData(); - - formData.append('conversation_name', conversationName); - formData.append('is_private', isPrivate ? '1' : '0') - formData.append('bound_service', boundServiceID ? boundServiceID : ''); - formData.append('is_live_conversation', createLiveConversation ? '1' : '0') - await fetchServer(`chat_api/new`, REQUEST_METHODS.POST, formData).then(async response => { - const responseJson = await response.json(); - let responseOk = false; - if (response.ok) { - await buildConversation(responseJson, CONVERSATION_SKINS.PROMPTS); - responseOk = true; + attachmentsButton.addEventListener('change', (e) => { + e.preventDefault(); + const fileName = getFilenameFromPath(e.currentTarget.value); + const lastFile = attachmentsButton.files[attachmentsButton.files.length - 1] + if (lastFile.size > configData['maxUploadSize']) { + console.warn(`Uploaded file is too big`); } else { - displayAlert('newConversationModalBody', - `${responseJson['msg']}`, - 'danger'); + addUpload(attachmentsButton.parentNode.parentNode.id, lastFile); + filenamesContainer.insertAdjacentHTML('afterbegin', + `${fileName}`); + filenamesContainer.style.display = ""; + if (filenamesContainer.children.length === configData['maxNumAttachments']) { + attachmentsButton.disabled = true; + } } - return responseOk; }); -} + await addRecorder(conversationData); + await initLanguageSelectors(conversationData['_id']); -document.addEventListener('DOMContentLoaded', (_) => { + if (skin === CONVERSATION_SKINS.BASE) { + const promptModeButton = document.getElementById(`prompt-mode-${conversationData['_id']}`); - if (configData['client'] === CLIENTS.MAIN) { - document.addEventListener('supportedLanguagesLoaded', async (_) => { - await refreshCurrentUser(false) - .then(async _ => await restoreChatAlignment()) - .then(async _ => await refreshCurrentUser(true)) - .then(async _ => await requestChatsLanguageRefresh()); - }); - addBySearch.addEventListener('click', async (e) => { + promptModeButton.addEventListener('click', async (e) => { e.preventDefault(); - displayConversation(conversationSearchInput.value, CONVERSATION_SKINS.PROMPTS, 'importConversationModalBody').then(responseOk => { - conversationSearchInput.value = ""; - if (responseOk) { - importConversationModal.modal('hide'); - } - }); - }); - conversationSearchInput.addEventListener('input', async (_) => { - await renderSuggestions(); + chatCloseButton.click(); + await displayConversation(conversationData['_id'], CONVERSATION_SKINS.PROMPTS, null, conversationParentID); }); - addNewConversation.addEventListener('click', async (e) => { + } else if (skin === CONVERSATION_SKINS.PROMPTS) { + chatCloseButton = document.getElementById(`close-prompts-${cid}`); + const baseModeButton = document.getElementById(`base-mode-${cid}`); + const exportToExcelBtn = document.getElementById(`${cid}-export-to-excel`) + + // TODO: fix here to use prompt- prefix + baseModeButton.addEventListener('click', async (e) => { e.preventDefault(); - const newConversationName = document.getElementById('conversationName'); - const isPrivate = document.getElementById('isPrivate'); - const createLiveConversation = document.getElementById("createLiveConversation"); - let boundServiceID = bindServiceSelect.value; + chatCloseButton.click(); + await displayConversation(cid, CONVERSATION_SKINS.BASE, null, conversationParentID); + }); - if (boundServiceID) { - const targetItem = document.getElementById(boundServiceID); - if (targetItem.value) { - if (targetItem.nodeName === 'SELECT') { - boundServiceID = targetItem.value; - } else { - boundServiceID = targetItem.getAttribute('data-value') + '.' + targetItem.value - } - } else { - displayAlert('newConversationModalBody', 'Missing bound service name'); - return -1; - } - } + // TODO: make an array of prompt tables only in dedicated conversation + Array.from(getMessagesOfCID(cid, MESSAGE_REFER_TYPE.ALL, 'prompt', false)).forEach(table => { + + table.addEventListener('mousedown', (_) => startSelection(table, exportToExcelBtn)); + table.addEventListener('touchstart', (_) => startSelection(table, exportToExcelBtn)); + table.addEventListener('mouseup', (_) => selectTable(table, exportToExcelBtn)); + table.addEventListener("touchend", (_) => selectTable(table, exportToExcelBtn)); - createNewConversation(newConversationName.value, isPrivate.checked, boundServiceID, createLiveConversation.checked).then(responseOk => { - newConversationName.value = ""; - isPrivate.checked = false; - if (responseOk) { - newConversationModal.modal('hide'); - } - }); - }); - importConversationOpener.addEventListener('click', async (e) => { - e.preventDefault(); - conversationSearchInput.value = ""; - await renderSuggestions(); }); - bindServiceSelect.addEventListener("change", function() { - Array.from(document.getElementsByClassName('create-conversation-bind-group')).forEach(x => { - x.hidden = true; + exportToExcelBtn.addEventListener('click', (e) => { + const selectedTables = messageListContainer.getElementsByClassName('selected'); + exportTablesToExcel(selectedTables, `prompts_of_${cid}`, 'prompt_{id}'); + Array.from(selectedTables).forEach(selectedTable => { + selectedTable.classList.remove('selected'); }); - if (bindServiceSelect.value) { - const targetItem = document.getElementById(bindServiceSelect.value); - targetItem.hidden = false; - } }); } -}); -/** - * Collection of supported clients, current client is matched based on client configuration - * @type {{NANO: string, MAIN: string}} - */ -const CLIENTS = { - MAIN: 'main', - NANO: 'nano', - UNDEFINED: undefined -} - -/** - * JS Object containing frontend configuration data - * @type {{staticFolder: string, currentURLBase: string, currentURLFull: (string|string|string|SVGAnimatedString|*), client: string}} - */ - -let configData = { - 'staticFolder': "../../static", - 'currentURLBase': extractURLBase(), - 'currentURLFull': window.location.href, - 'client': typeof metaConfig !== 'undefined' ? metaConfig?.client : CLIENTS.UNDEFINED, - "MAX_CONVERSATIONS_PER_PAGE": 4, -}; -/** - * Default key for storing data in local storage - * @type {string} - */ -const conversationAlignmentKey = 'conversationAlignment'; + if (chatCloseButton.hasAttribute('data-target-cid')) { + chatCloseButton.addEventListener('click', async (_) => { + conversationHolder.removeChild(conversationParent); + await removeConversation(cid); + clearStateCache(cid); + resizeConversationContainers() + }); + } + // Hide close button for Nano Frames + if (configData.client === CLIENTS.NANO) { + chatCloseButton.hidden = true; + } + document.getElementById('klatchatHeader').scrollIntoView(true); + return cid; +} /** - * Custom Event fired on configs ended up loading - * @type {CustomEvent} + * Gets conversation data based on input string + * @param input - input string text + * @param oldestMessageTS - creation timestamp of the oldest displayed message + * @param skin - resolves by server for which data to return + * @param maxResults - max number of messages to fetch + * @returns {Promise<{}>} promise resolving conversation data returned */ -const configFullLoadedEvent = new CustomEvent("configLoaded", { - "detail": "Event that is fired when configs are loaded" -}); +async function getConversationDataByInput(input, skin, oldestMessageTS = null, maxResults = 10) { + let conversationData = {}; + if (input) { + let query_url = `chat_api/search/${input.toString()}?limit_chat_history=${maxResults}&skin=${skin}`; + if (oldestMessageTS) { + query_url += `&creation_time_from=${oldestMessageTS}`; + } + await fetchServer(query_url) + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw response.statusText; + } + }) + .then(data => { + if (getUserMessages(data, null).length === 0) { + console.log('All of the messages are already displayed'); + setDefault(setDefault(conversationState, data['_id'], {}), 'all_messages_displayed', true); + } + conversationData = data; + }).catch(async err => { + console.warn('Failed to fulfill request due to error:', err); + }); + } + return conversationData; +} /** - * Convenience method for getting URL base for current page - * @returns {string} constructed URL base + * Returns table representing chat alignment + * @return {Table} */ -function extractURLBase() { - return window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : ''); +const getChatAlignmentTable = () => { + return getDb(DATABASES.CHATS, DB_TABLES.CHAT_ALIGNMENT); } /** - * Extracts json data from provided URL path - * @param urlPath - file path string - * @param onError - callback on extraction failure - * @returns {Promise<* | {}>} promise that resolves data obtained from file path + * Retrieves conversation layout from local storage + * @returns {Array} collection of database-stored elements */ -async function extractJsonData(urlPath = "", - onError = (e) => console.error(`failed to extractJsonData - ${e}`)) { - return fetch(urlPath).then(response => { - if (response.ok) { - return response.json(); - } - return {}; - }).catch(onError); +async function retrieveItemsLayout(idOnly = false) { + let layout = await getChatAlignmentTable().orderBy("added_on").toArray(); + if (idOnly) { + layout = layout.map(a => a.cid); + } + return layout; } -document.addEventListener('DOMContentLoaded', async (e) => { - if (configData['client'] === CLIENTS.MAIN) { - configData = Object.assign(configData, await extractJsonData(`${configData['currentURLBase']}/base/runtime_config`), (e) => location.reload()); - document.dispatchEvent(configFullLoadedEvent); - } -}); -const REQUEST_METHODS = { - GET: 'GET', - PUT: 'PUT', - DELETE: 'DELETE', - POST: 'POST' +/** + * Adds new conversation id to local storage + * @param cid - conversation id to add + * @param skin - conversation skin to add + */ +async function addNewCID(cid, skin) { + return await getChatAlignmentTable().put({ + 'cid': cid, + 'skin': skin, + 'added_on': getCurrentTimestamp() + }, [cid]); } -const controllers = new Set(); - - -const getSessionToken = () => { - return localStorage.getItem('session') || ''; +/** + * Removed conversation id from local storage + * @param cid - conversation id to remove + */ +async function removeConversation(cid) { + return await Promise.all([DBGateway.getInstance(DB_TABLES.CHAT_ALIGNMENT).deleteItem(cid), + DBGateway.getInstance(DB_TABLES.CHAT_MESSAGES_PAGINATION).deleteItem(cid) + ]); } -const setSessionToken = (val) => { - const currentValue = getSessionToken(); - localStorage.setItem('session', val); - if (currentValue && currentValue !== val) { - location.reload(); - } +/** + * Checks if conversation is displayed + * @param cid - target conversation id + * + * @return true if cid is stored in client db, false otherwise + */ +function isDisplayed(cid) { + return document.getElementById(cid) !== null; } -const fetchServer = async (urlSuffix, method = REQUEST_METHODS.GET, body = null, json = false) => { - const controller = new AbortController(); - controllers.add(controller); - const signal = controller.signal; - const options = { - method: method, - headers: new Headers({ - 'Authorization': getSessionToken() - }), - signal, - } - if (body) { - options['body'] = body; - } - // TODO: there is an issue validating FormData on backend, so JSON property should eventually become true - if (json) { - options['headers'].append('Content-Type', 'application/json'); - if (options['body']) { - options['body'] &&= JSON.stringify(options['body']) - } - } - return fetch(`${configData["CHAT_SERVER_URL_BASE"]}/${urlSuffix}`, options).then(async response => { - if (response.status === 401) { - const responseJson = await response.json(); - if (responseJson['msg'] === 'Session token is invalid or expired') { - localStorage.removeItem('session'); - location.reload(); - } - } - return response; - }).finally(() => { - controllers.delete(controller); - }); +/** + * Gets value of desired property in stored conversation + * @param cid - target conversation id + * + * @return true if cid is displayed, false otherwise + */ +async function getStoredConversationData(cid) { + return await getChatAlignmentTable().where({ + cid: cid + }).first(); } - -document.addEventListener('beforeunload', () => { - for (const controller of controllers) { - controller.abort(); +/** + * Returns current skin of provided conversation id + * @param cid - target conversation id + * + * @return {string} skin from CONVERSATION_SKINS + */ +async function getCurrentSkin(cid) { + const storedCID = await getStoredConversationData(cid); + if (storedCID) { + return storedCID['skin']; } -}); -let __inputFileList = {}; + return null; +} /** - * Gets uploaded files from specified conversation id - * @param cid specified conversation id - * @return {*} list of files from specified cid if any + * Boolean function that checks whether live chats must be displayed based on page meta properties + * @returns {boolean} true if live chat should be displayed, false otherwise */ -function getUploadedFiles(cid) { - if (__inputFileList.hasOwnProperty(cid)) { - return __inputFileList[cid]; +const shouldDisplayLiveChat = () => { + const liveMetaElem = document.querySelector("meta[name='live']"); + if (liveMetaElem) { + return liveMetaElem.getAttribute("content") === "1" } - return []; + return false } /** - * Cleans uploaded files per conversation + * Fetches latest live conversation from the klat server API and builds its HTML + * @returns {Promise<*>} fetched conversation data */ -function cleanUploadedFiles(cid) { - if (__inputFileList.hasOwnProperty(cid)) { - delete __inputFileList[cid]; - } - const attachmentsButton = document.getElementById('file-input-' + cid); - attachmentsButton.value = ""; - const fileContainer = document.getElementById('filename-container-' + cid); - fileContainer.innerHTML = ""; +const displayLiveChat = async () => { + return await fetchServer('chat_api/live') + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw response.statusText; + } + }) + .then(data => { + if (getUserMessages(data, null).length === 0) { + console.debug('All of the messages are already displayed'); + setDefault(setDefault(conversationState, data['_id'], {}), 'all_messages_displayed', true); + } + return data; + }) + .then( + async data => { + await buildConversation(data, CONVERSATION_SKINS.PROMPTS, true); + return data; + } + ) + .catch(async err => { + console.warn('Failed to display live chat:', err); + }); } /** - * Adds File upload to specified cid - * @param cid: mentioned cid - * @param file: File object + * Restores chat alignment based on the page cache */ -function addUpload(cid, file) { - if (!__inputFileList.hasOwnProperty(cid)) { - __inputFileList[cid] = []; +const restoreChatAlignmentFromCache = async () => { + let cachedItems = await retrieveItemsLayout(); + if (cachedItems.length === 0) { + await displayLiveChat(); + } + for (const item of cachedItems) { + await getConversationDataByInput(item.cid, item.skin).then(async conversationData => { + if (conversationData && Object.keys(conversationData).length > 0) { + await buildConversation(conversationData, item.skin, false); + } else { + if (item.cid !== '1') { + displayAlert(document.getElementById('conversationsBody'), 'No matching conversation found', 'danger', 'noRestoreConversationAlert', { + 'type': alertBehaviors.AUTO_EXPIRE + }); + } + await removeConversation(item.cid); + } + }); } - __inputFileList[cid].push(file); } /** - * Adds download request on attachment item click - * @param attachmentItem: desired attachment item - * @param cid: current conversation id - * @param messageID: current message id + * Custom Event fired on supported languages init + * @type {CustomEvent} */ -async function downloadAttachment(attachmentItem, cid, messageID) { - if (attachmentItem) { - const fileName = attachmentItem.getAttribute('data-file-name'); - const mime = attachmentItem.getAttribute('data-mime'); - const getFileURL = `files/${messageID}/get_attachment/${fileName}`; - await fetchServer(getFileURL).then(async response => { - response.ok ? - download(await response.blob(), fileName, mime) : - console.error(`No file data received for path, -cid=${cid};\n -message_id=${messageID};\n -file_name=${fileName}`) - }).catch(err => console.error(`Failed to fetch: ${getFileURL}: ${err}`)); +const chatAlignmentRestoredEvent = new CustomEvent("chatAlignmentRestored", { + "detail": "Event that is fired when chat alignment is restored" +}); + +/** + * Restores chats alignment from the local storage + **/ +async function restoreChatAlignment() { + if (shouldDisplayLiveChat()) { + await displayLiveChat(); + } else { + await restoreChatAlignmentFromCache(); } + console.debug('Chat Alignment Restored'); + document.dispatchEvent(chatAlignmentRestoredEvent); } + /** - * Attaches message replies to initialized conversation - * @param conversationData: conversation data object + * Helper struct to decide on which kind of messages to refer + * "all" - all the messages + * "mine" - only the messages emitted by current user + * "others" - all the messages except "mine" */ -function addAttachments(conversationData) { - if (conversationData.hasOwnProperty('chat_flow')) { - getUserMessages(conversationData).forEach(message => { - resolveMessageAttachments(conversationData['_id'], message['message_id'], message?.attachments); - }); - } +const MESSAGE_REFER_TYPE = { + ALL: 'all', + MINE: 'mine', + OTHERS: 'other' } /** - * Activates attachments event listeners for message attachments in specified conversation - * @param cid: desired conversation id - * @param elem: parent element for attachment (defaults to document) + * Gets array of messages for provided conversation id + * @param cid - target conversation id + * @param messageReferType - message refer type to consider from `MESSAGE_REFER_TYPE` + * @param idOnly - to return id only (defaults to false) + * @param forceType - to get only the certain type of messages (optional) + * + * @return array of message DOM objects under given conversation */ -function activateAttachments(cid, elem = null) { - if (!elem) { - elem = document; - } - Array.from(elem.getElementsByClassName('attachment-item')).forEach(attachmentItem => { - attachmentItem.addEventListener('click', async (e) => { - e.preventDefault(); - const attachmentName = attachmentItem.getAttribute('data-file-name'); +function getMessagesOfCID(cid, messageReferType = MESSAGE_REFER_TYPE.ALL, forceType = null, idOnly = false) { + let messages = [] + const messageContainer = getMessageListContainer(cid); + if (messageContainer) { + const listItems = messageContainer.getElementsByTagName('li'); + Array.from(listItems).forEach(li => { try { - setChatState(cid, 'updating', `Downloading attachment file`); - await downloadAttachment(attachmentItem, cid, attachmentItem.parentNode.parentNode.id); + const messageNode = getMessageNode(li, forceType); + if (messageNode) { + if (messageReferType === MESSAGE_REFER_TYPE.ALL || + (messageReferType === MESSAGE_REFER_TYPE.MINE && messageNode.getAttribute('data-sender') === currentUser['nickname']) || + (messageReferType === MESSAGE_REFER_TYPE.OTHERS && messageNode.getAttribute('data-sender') !== currentUser['nickname'])) { + if (idOnly) { + messages.push(messageNode.id); + } else { + messages.push(messageNode); + } + } + } } catch (e) { - console.warn(`Failed to download attachment file - ${attachmentName} (${e})`) - } finally { - setChatState(cid, 'active'); + console.warn(`Failed to get message under node: ${li} - ${e}`); } }); - }); + } + return messages; } +/** + * Refreshes chat view (for instance when user session gets updated) + */ +function refreshChatView(conversationContainer = null) { + if (!conversationContainer) { + conversationContainer = conversationBody; + } + Array.from(conversationContainer.getElementsByClassName('conversationContainer')).forEach(async conversation => { + const cid = conversation.getElementsByClassName('card')[0].id; + const skin = await getCurrentSkin(cid); + if (skin === CONVERSATION_SKINS.BASE) { + const messages = getMessagesOfCID(cid, MESSAGE_REFER_TYPE.ALL, 'plain'); + Array.from(messages).forEach(message => { + if (message.hasAttribute('data-sender')) { + const messageSenderNickname = message.getAttribute('data-sender'); + if (message.parentElement.parentElement.className !== 'announcement') + message.parentElement.parentElement.className = (currentUser && messageSenderNickname === currentUser['nickname']) ? 'in' : 'out'; + } + }); + } + await initLanguageSelectors(cid); + }); +} /** - * Returns DOM element to include as file resolver based on its name - * @param filename: name of file to fetch - * @return {string}: resulting DOM element + * Enum of possible displayed chat states + * "active" - ready to be used by user + * "updating" - in processes of applying changes, temporary unavailable */ -function attachmentHTMLBasedOnFilename(filename) { +const CHAT_STATES = { + ACTIVE: 'active', + UPDATING: 'updating', +} - let fSplitted = filename.split('.'); - if (fSplitted.length > 1) { - const extension = fSplitted.pop(); - const shrinkedName = shrinkToFit(filename, 12, `...${extension}`); - if (IMAGE_EXTENSIONS.includes(extension)) { - return ` ${shrinkedName}`; - } else { - return shrinkedName; +/** + * Sets state to the desired cid + * @param cid - target conversation id + * @param state - the new chat state from `CHAT_STATES` + * @param state_msg - reason for state transitioning (optional) + */ +function setChatState(cid, state = CHAT_STATES.ACTIVE, state_msg = '') { + // TODO: refactor this method to handle when there are multiple messages on a stack + // console.log(`cid=${cid}, state=${state}, state_msg=${state_msg}`) + const cidNode = document.getElementById(cid); + if (cidNode) { + setDefault(setDefault(conversationState, cid, {})) + const spinner = document.getElementById(`${cid}-spinner`); + const spinnerUpdateMsg = document.getElementById(`${cid}-update-msg`); + if (state === 'updating') { + cidNode.classList.add('chat-loading'); + spinner.style.setProperty('display', 'flex', 'important'); + spinnerUpdateMsg.innerHTML = state_msg; + } else if (state === 'active') { + cidNode.classList.remove('chat-loading'); + spinner.style.setProperty('display', 'none', 'important'); + spinnerUpdateMsg.innerHTML = ''; } + conversationState[cid]['state'] = state; + conversationState[cid]['state_message'] = state_msg; } - return shrinkToFit(filename, 12); } /** - * Resolves attachments to the message - * @param cid: id of conversation - * @param messageID: id of user message - * @param attachments list of attachments received + * Displays first conversation matching search string + * @param searchStr - Search string to find matching conversation + * @param skin - target conversation skin to display + * @param alertParentID - id of the element to display alert in + * @param conversationParentID - parent Node ID of the conversation */ -function resolveMessageAttachments(cid, messageID, attachments = []) { - if (messageID) { - const messageElem = document.getElementById(messageID); - if (messageElem) { - const attachmentToggle = messageElem.getElementsByClassName('attachment-toggle')[0]; - if (attachments.length > 0) { - if (messageElem) { - const attachmentPlaceholder = messageElem.getElementsByClassName('attachments-placeholder')[0]; - attachments.forEach(attachment => { - const attachmentHTML = ` -${attachmentHTMLBasedOnFilename(attachment['name'])} -
`; - attachmentPlaceholder.insertAdjacentHTML('afterbegin', attachmentHTML); - }); - attachmentToggle.addEventListener('click', (e) => { - attachmentPlaceholder.style.display = attachmentPlaceholder.style.display === "none" ? "" : "none"; - }); - activateAttachments(cid, attachmentPlaceholder); - attachmentToggle.style.display = ""; - // attachmentPlaceholder.style.display = ""; - } +async function displayConversation(searchStr, skin = CONVERSATION_SKINS.PROMPTS, alertParentID = null, conversationParentID = 'conversationsBody') { + if (getOpenedChatIds().length === configData.MAX_CONVERSATIONS_PER_PAGE) { + alert(`Up to ${configData.MAX_CONVERSATIONS_PER_PAGE} allowed per page`) + } else if (searchStr !== "") { + const alertParent = document.getElementById(alertParentID || conversationParentID); + await getConversationDataByInput(searchStr, skin, null, 10).then(async conversationData => { + let responseOk = false; + if (!conversationData || Object.keys(conversationData).length === 0) { + displayAlert( + alertParent, + 'Cannot find conversation matching your search', + 'danger', + 'noSuchConversationAlert', { + 'type': alertBehaviors.AUTO_EXPIRE + } + ); + } else if (isDisplayed(conversationData['_id'])) { + displayAlert(alertParent, 'Chat is already displayed', 'danger'); } else { - attachmentToggle.style.display = "none"; + await buildConversation(conversationData, skin, true, conversationParentID); + if (skin === CONVERSATION_SKINS.BASE) { + for (const inputType of ['incoming', 'outcoming']) { + await requestTranslation(conversationData['_id'], null, null, inputType); + } + } + responseOk = true; + if (configData.client === CLIENTS.NANO) { + attachEditModalInvoker(document.getElementById(`${conversationData['_id']}-account-link`)); + updateNavbar(); + initSettings(document.getElementById(`${conversationData['_id']}-settings-link`)); + } } - } + return responseOk; + }); } } + /** - * Returns current UNIX timestamp in seconds - * @return {number}: current unix timestamp + * Handles requests on creation new conversation by the user + * @param conversationName - New Conversation Name + * @param isPrivate - if conversation should be private (defaults to false) + * @param boundServiceID - id of the service to bind to conversation (optional) + * @param createLiveConversation - if conversation should be treated as live conversation (defaults to false) */ -const getCurrentTimestamp = () => { - return Math.floor(Date.now() / 1000); -}; +async function createNewConversation(conversationName, isPrivate = false, boundServiceID = null, createLiveConversation = false) { -// Client's timer -// TODO consider refactoring to "timer per component" if needed -let __timer = 0; + let formData = new FormData(); + + formData.append('conversation_name', conversationName); + formData.append('is_private', isPrivate ? '1' : '0') + formData.append('bound_service', boundServiceID ? boundServiceID : ''); + formData.append('is_live_conversation', createLiveConversation ? '1' : '0') + await fetchServer(`chat_api/new`, REQUEST_METHODS.POST, formData).then(async response => { + const responseJson = await response.json(); + let responseOk = false; + if (response.ok) { + await buildConversation(responseJson, CONVERSATION_SKINS.PROMPTS); + responseOk = true; + } else { + displayAlert('newConversationModalBody', + `${responseJson['msg']}`, + 'danger'); + } + return responseOk; + }); +} -/** - * Sets timer to current timestamp - */ -const startTimer = () => { - __timer = Date.now(); -}; +document.addEventListener('DOMContentLoaded', (_) => { -/** - * Resets times and returns time elapsed since invocation of startTimer() - * @return {number} Number of seconds elapsed - */ -const stopTimer = () => { - const timeDue = Date.now() - __timer; - __timer = 0; - return timeDue; -}; + if (configData['client'] === CLIENTS.MAIN) { + document.addEventListener('supportedLanguagesLoaded', async (_) => { + await refreshCurrentUser(false) + .then(async _ => await restoreChatAlignment()) + .then(async _ => await refreshCurrentUser(true)) + .then(async _ => await requestChatsLanguageRefresh()); + }); + addBySearch.addEventListener('click', async (e) => { + e.preventDefault(); + displayConversation(conversationSearchInput.value, CONVERSATION_SKINS.PROMPTS, 'importConversationModalBody').then(responseOk => { + conversationSearchInput.value = ""; + if (responseOk) { + importConversationModal.modal('hide'); + } + }); + }); + conversationSearchInput.addEventListener('input', async (_) => { + await renderSuggestions(); + }); + addNewConversation.addEventListener('click', async (e) => { + e.preventDefault(); + const newConversationName = document.getElementById('conversationName'); + const isPrivate = document.getElementById('isPrivate'); + const createLiveConversation = document.getElementById("createLiveConversation"); + let boundServiceID = bindServiceSelect.value; + + if (boundServiceID) { + const targetItem = document.getElementById(boundServiceID); + if (targetItem.value) { + if (targetItem.nodeName === 'SELECT') { + boundServiceID = targetItem.value; + } else { + boundServiceID = targetItem.getAttribute('data-value') + '.' + targetItem.value + } + } else { + displayAlert('newConversationModalBody', 'Missing bound service name'); + return -1; + } + } + + createNewConversation(newConversationName.value, isPrivate.checked, boundServiceID, createLiveConversation.checked).then(responseOk => { + newConversationName.value = ""; + isPrivate.checked = false; + if (responseOk) { + newConversationModal.modal('hide'); + } + }); + }); + importConversationOpener.addEventListener('click', async (e) => { + e.preventDefault(); + conversationSearchInput.value = ""; + await renderSuggestions(); + }); + bindServiceSelect.addEventListener("change", function() { + Array.from(document.getElementsByClassName('create-conversation-bind-group')).forEach(x => { + x.hidden = true; + }); + if (bindServiceSelect.value) { + const targetItem = document.getElementById(bindServiceSelect.value); + targetItem.hidden = false; + } + }); + } +}); const configNanoLoadedEvent = new CustomEvent("configNanoLoaded", { "detail": "Event that is fired when nano configs are loaded" }); diff --git a/version.py b/version.py index d1270ea5..75590e28 100644 --- a/version.py +++ b/version.py @@ -26,5 +26,5 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.4.12a1" -__version_ts__ = 1738931283 +__version__ = "0.4.12a2" +__version_ts__ = 1739470832