From ddb6f87fb8d6b7f1fc9d074dd6863a83b45daee4 Mon Sep 17 00:00:00 2001 From: enured <enis.nuredini@uzh.ch> Date: Fri, 5 Jan 2024 18:17:11 +0100 Subject: [PATCH] F17462: Final V1.0. refs #17462 --- extension/Classes/Core/AbstractBuildForm.php | 4 +- extension/Classes/Core/Constants.php | 48 ++++ extension/Classes/Core/Form/Chat.php | 223 +++++++++++-------- extension/Classes/Core/QuickFormQuery.php | 21 +- extension/Classes/Core/Save.php | 14 -- extension/Classes/Sql/qfqDefaultTables.sql | 29 +-- javascript/src/Helper/qfqChat.js | 96 ++++++-- 7 files changed, 291 insertions(+), 144 deletions(-) diff --git a/extension/Classes/Core/AbstractBuildForm.php b/extension/Classes/Core/AbstractBuildForm.php index 40f4a3444..c1f1fe278 100644 --- a/extension/Classes/Core/AbstractBuildForm.php +++ b/extension/Classes/Core/AbstractBuildForm.php @@ -3169,12 +3169,12 @@ abstract class AbstractBuildForm { $chatWindowAttribute .= Support::doAttribute('data-load-api', $this->sip->queryStringToSip(Path::urlApi(API_LOAD_PHP) . '?' . 'mode=form_chat_load&chat_config=' . $chatConfigJson, RETURN_URL)); $chatWindowAttribute .= Support::doAttribute('data-save-api', $this->sip->queryStringToSip(Path::urlApi(API_SAVE_PHP) . '?' . 'mode=form_chat_save&chat_config=' . $chatConfigJson, RETURN_URL)); - $chatHead = '<div class="qfq-chat-window" ' . $chatWindowAttribute . '><span class="fas fa-search chat-search-activate qfq-skip-dirty"></span><div class="chat-search"><input type="text" class="chat-search-input qfq-skip-dirty" placeholder="Search..." ' . $chatConfig['disabledFlag'] . '><button class="chat-search-btn qfq-skip-dirty" ' . $chatConfig['disabledFlag'] . '>Search</button><span class="chat-search-info"></span></div><div class="chat-messages"><div class="fas fa-arrow-circle-up chat-top-symbol"></div>'; + $chatHead = '<div class="qfq-chat-window" ' . $chatWindowAttribute . '><span class="fas fa-search chat-search-activate qfq-skip-dirty"></span><div class="chat-search"><input type="text" class="chat-search-input qfq-skip-dirty" placeholder="Search..."><button class="chat-search-btn qfq-skip-dirty">Search</button><span class="chat-search-info"></span></div><div class="chat-messages"><div class="fas fa-arrow-circle-up chat-top-symbol"></div>'; $chatTail = '</div></div>'; $jsonChat = Chat::createChatJson($chatConfig, $this->dbArray[$this->dbIndexQfq]); - if ($jsonChat['xId'] == 0) { + if (empty($jsonChat[FE_TYPE_CHAT])) { $chatContent = '<div class="chat-no-message"><span class="label label-default">No messages found</span></div>'; } else { $chatContent = '<div class="chat-loader-container" style="display: none;"><div class="chat-loader"></div></div>'; diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php index c3764e663..30ae3ce31 100644 --- a/extension/Classes/Core/Constants.php +++ b/extension/Classes/Core/Constants.php @@ -948,6 +948,8 @@ const API_FORM_UPDATE_DISABLED = 'disabled'; const API_FORM_UPDATE_REQUIRED = 'required'; const API_HEADER_VARIABLE_KEY = 'apiResponseHeader'; +const API_CHAT_UPDATE_DISABLED = API_FORM_UPDATE_DISABLED; + const API_ELEMENT_UPDATE = 'element-update'; const API_ELEMENT_ATTRIBUTE = 'attr'; const API_ELEMENT_CONTENT = 'content'; @@ -1695,6 +1697,52 @@ const FILE_PRIORITY = 'filePriority'; const FILE_MODE_WRITE = 'w'; const FILE_MODE_APPEND = 'a'; +// Chat constants +const CHAT_CLIENT_MESSAGE = 'message'; +const CHAT_CLIENT_MESSAGE_ID_LIST = 'messageIdList'; +const CHAT_DB_COLUMN_NAMES = 'dbColumnNames'; +const CHAT_TABLE_NAME = 'tableName'; +const CHAT_COLUMN_MAP = 'columnMap'; +const CHAT_TYPE_MESSAGE = CHAT_CLIENT_MESSAGE; +const CHAT_COLUMN_ID = 'id'; +const CHAT_COLUMN_XID = 'xId'; +const CHAT_COLUMN_USERNAME = 'username'; +const CHAT_COLUMN_MESSAGE = 'message'; +const CHAT_COLUMN_PID_CREATOR = 'pIdCreator'; +const CHAT_COLUMN_PID_TEAM = 'pIdTeam'; +const CHAT_COLUMN_CID_TOPIC = 'cIdTopic'; +const CHAT_COLUMN_CID_THREAD = 'cIdThread'; +const CHAT_COLUMN_X_GRID_STATUS = 'xGrIdStatus'; +const CHAT_COLUMN_TYPE = 'type'; +const CHAT_COLUMN_TYPE_MESSAGE = 'message'; +const CHAT_COLUMN_TYPE_TAG = 'tag'; +const CHAT_COLUMN_CREATED = 'created'; + +const CHAT_LOAD_MODE_REFRESH = 'refresh'; +const CHAT_LOAD_MODE_PING = 'ping'; +const CHAT_LOAD_MODE_FIRST = 'first'; + +const CHAT_MODE_SAVE = 'chat_save'; +const CHAT_MODE_LOAD = 'chat_load'; + +const CHAT_CONFIG_SIP = 'chat_config'; + +const CHAT_DONE = 'chatDone'; +const CHAT_WEBSOCKET_RECEIVER_IDS = 'receiverIds'; +const CHAT_FLAG_MORE_RECORDS = 'flagMoreRecords'; + +const CHAT_PARAMETER_XID = CHAT_COLUMN_XID; +const CHAT_PARAMETER_PID_CREATOR = CHAT_COLUMN_PID_CREATOR; +const CHAT_PARAMETER_USERNAME = CHAT_COLUMN_USERNAME; +const CHAT_PARAMETER_CID_TOPIC = CHAT_COLUMN_CID_TOPIC; +const CHAT_PARAMETER_OPTION_DONE = 'optionDone'; +const CHAT_PARAMETER_OPTION_REMINDER = 'optionReminder'; +const CHAT_PARAMETER_THREAD = 'thread'; +const CHAT_PARAMETER_PID_TEAM = CHAT_COLUMN_PID_TEAM; +const CHAT_PARAMETER_X_GRID_STATUS = CHAT_COLUMN_X_GRID_STATUS; + +const CHAT_TYPE_TAG_DONE = '__done'; + // DATABASE const DB_NUM_ROWS = 'numRows'; const DB_AFFECTED_ROWS = 'affectedRows'; diff --git a/extension/Classes/Core/Form/Chat.php b/extension/Classes/Core/Form/Chat.php index 8018d8362..9402ac81e 100644 --- a/extension/Classes/Core/Form/Chat.php +++ b/extension/Classes/Core/Form/Chat.php @@ -25,11 +25,14 @@ use Ratchet\ConnectionInterface; * @package qfq */ class Chat implements MessageComponentInterface { - protected $clients; - protected $logFile; - protected $clientInfo; + protected \SplObjectStorage $clients; + protected string $logFile; + protected array $clientInfo; protected $clientConnections; + /** + * @throws \UserFormException + */ public function __construct() { $this->clients = new \SplObjectStorage; $this->logFile = Path::absoluteWebsocketLogFile(); @@ -71,7 +74,7 @@ class Chat implements MessageComponentInterface { if (!empty($this->clientInfo[$from->resourceId])) { // send ping to client with given receiverId - foreach ($this->clientInfo[$from->resourceId]['receiverIds'] as $receiverId) { + foreach ($this->clientInfo[$from->resourceId][CHAT_WEBSOCKET_RECEIVER_IDS] as $receiverId) { if (isset($this->clientConnections[$receiverId])) { $this->clientConnections[$receiverId]->send($data->data); } @@ -94,37 +97,38 @@ class Chat implements MessageComponentInterface { } private function updateReceiverIds($from): void { - if (!isset($this->clientInfo[$from->resourceId]['receiverIds'])) { - $this->clientInfo[$from->resourceId]['receiverIds'] = array(); + if (!isset($this->clientInfo[$from->resourceId][CHAT_WEBSOCKET_RECEIVER_IDS])) { + $this->clientInfo[$from->resourceId][CHAT_WEBSOCKET_RECEIVER_IDS] = array(); } foreach ($this->clients as $client) { - $fromCreator = $this->clientInfo[$from->resourceId]['pIdCreator'] ?? null; - $clientCreator = $this->clientInfo[$client->resourceId]['pIdCreator'] ?? null; + $fromCreator = $this->clientInfo[$from->resourceId][CHAT_COLUMN_PID_CREATOR] ?? null; + $clientCreator = $this->clientInfo[$client->resourceId][CHAT_COLUMN_PID_CREATOR] ?? null; - $fromRecordId = $this->clientInfo[$from->resourceId]['xId'] ?? null; - $clientRecordId = $this->clientInfo[$client->resourceId]['xId'] ?? null; + $fromRecordId = $this->clientInfo[$from->resourceId][CHAT_COLUMN_XID] ?? null; + $clientRecordId = $this->clientInfo[$client->resourceId][CHAT_COLUMN_XID] ?? null; - $fromTopicId = $this->clientInfo[$from->resourceId]['cIdTopic'] ?? null; - $clientTopicId = $this->clientInfo[$client->resourceId]['cIdTopic'] ?? null; + $fromTopicId = $this->clientInfo[$from->resourceId][CHAT_COLUMN_CID_TOPIC] ?? null; + $clientTopicId = $this->clientInfo[$client->resourceId][CHAT_COLUMN_CID_TOPIC] ?? null; - $fromThreadId = $this->clientInfo[$from->resourceId]['cIdThread'] ?? null; - $clientThreadId = $this->clientInfo[$client->resourceId]['cIdThread'] ?? null; + $fromThreadId = $this->clientInfo[$from->resourceId][CHAT_COLUMN_CID_THREAD] ?? null; + $clientThreadId = $this->clientInfo[$client->resourceId][CHAT_COLUMN_CID_THREAD] ?? null; $isDifferentClient = $from !== $client; - $isNotInReceiverIds = !in_array($client->resourceId, $this->clientInfo[$from->resourceId]['receiverIds']); + $isNotInReceiverIds = !in_array($client->resourceId, $this->clientInfo[$from->resourceId][CHAT_WEBSOCKET_RECEIVER_IDS]); $isValidCreator = empty($fromGroupList) && $fromCreator === $clientCreator; $isValidRecordId = $fromRecordId === $clientRecordId; $isValidTopicId = $fromTopicId === $clientTopicId; if ($isDifferentClient && $isValidRecordId && $isNotInReceiverIds && $isValidTopicId) { - $this->clientInfo[$from->resourceId]['receiverIds'][] = $client->resourceId; + $this->clientInfo[$from->resourceId][CHAT_WEBSOCKET_RECEIVER_IDS][] = $client->resourceId; } } } - /** + /** Create chat configuration + * * @param array $formElement * @param Store $store * @return array @@ -137,153 +141,198 @@ class Chat implements MessageComponentInterface { $chatConfig = array(); // [apId:rId,pId:pIdCreator,grIdStatus:xGrIdStatus,grIdGroup:grIdGroupList,...] - $chatConfig['dbColumnNames'] = array ( + $chatConfig[CHAT_DB_COLUMN_NAMES] = array ( 'id' => 'id', 'xId' => 'xId', 'pIdCreator' => 'pIdCreator', - 'xGrIdStatus' => 'xGrIdStatus', + 'pIdTeam' => 'pIdTeam', 'cIdTopic' => 'cIdTopic', 'cIdThread' => 'cIdThread', + 'cIdLastRead' => 'cIdLastRead', + 'xGrIdStatus' => 'xGrIdStatus', 'type' => 'type', - 'done' => 'done', + 'emoticon' => 'emoticon', 'reference' => 'reference', 'message' => 'message', 'username' => 'username', 'created' => 'created' ); - $chatConfig['columnMap'] = $formElement['columnMap'] ?? '[]'; - - OnArray::mapColumns($chatConfig['dbColumnNames'], $chatConfig['columnMap']); - $chatConfig['tableName'] = $formElement['tableName'] ?? 'Chat'; + $chatConfig[CHAT_COLUMN_MAP] = $formElement[CHAT_COLUMN_MAP] ?? '[]'; - $chatConfig['xId'] = $formElement['xId'] ?? 0; - $chatConfig['pIdCreator'] = empty($formElement['pIdCreator']) ? 0 : $formElement['pIdCreator']; - $chatConfig['pIdTeam'] = $formElement['pIdTeam'] ?? 0; - $chatConfig['username'] = empty($formElement['username']) ? 'Anonymous' : $formElement['username']; - $chatConfig['xGrIdStatus'] = $formElement['xGrIdStatus'] ?? 0; - $chatConfig['cIdTopic'] = $formElement['cIdTopic'] ?? 0; - $chatConfig['optionDone'] = $formElement['optionDone'] ?? 'off'; - $chatConfig['optionReminder'] = $formElement['optionReminder'] ?? 0; - $chatConfig['thread'] = $formElement['thread'] ?? 0; + OnArray::mapColumns($chatConfig[CHAT_DB_COLUMN_NAMES], $chatConfig[CHAT_COLUMN_MAP]); + $chatConfig[CHAT_TABLE_NAME] = $formElement[CHAT_TABLE_NAME] ?? 'Chat'; - $chatConfig['disabledFlag'] = $formElement['disabledFlag'] ?? ''; + $chatConfig[CHAT_COLUMN_XID] = $formElement[CHAT_PARAMETER_XID] ?? 0; + $chatConfig[CHAT_COLUMN_PID_CREATOR] = empty($formElement[CHAT_PARAMETER_PID_CREATOR]) ? 0 : $formElement[CHAT_PARAMETER_PID_CREATOR]; + $chatConfig[CHAT_COLUMN_PID_TEAM] = $formElement[CHAT_PARAMETER_PID_TEAM] ?? 0; + $chatConfig[CHAT_COLUMN_USERNAME] = empty($formElement[CHAT_PARAMETER_USERNAME]) ? 'Anonym' : $formElement[CHAT_PARAMETER_USERNAME]; + $chatConfig[CHAT_COLUMN_X_GRID_STATUS] = $formElement[CHAT_PARAMETER_X_GRID_STATUS] ?? 0; + $chatConfig[CHAT_COLUMN_CID_TOPIC] = $formElement[CHAT_PARAMETER_CID_TOPIC] ?? 0; + $chatConfig[CHAT_PARAMETER_OPTION_DONE] = $formElement[CHAT_PARAMETER_OPTION_DONE] ?? 'off'; + $chatConfig[CHAT_PARAMETER_OPTION_REMINDER] = $formElement[CHAT_PARAMETER_OPTION_REMINDER] ?? 0; + $chatConfig[CHAT_PARAMETER_THREAD] = $formElement[CHAT_PARAMETER_THREAD] ?? 0; return $chatConfig; } - public static function createChatJson($chatConfig, Database $db, $messageIdList = '', $loadMode = 'refresh'): array { + /** Fetch from client requested messages and return them as a json. + * + * @param $chatConfig + * @param Database $db + * @param string $messageIdList + * @param string $loadMode + * @return array + * @throws \CodeException + * @throws \DbException + * @throws \UserFormException + */ + public static function createChatJson($chatConfig, Database $db, string $messageIdList = '', string $loadMode = CHAT_LOAD_MODE_REFRESH): array { $json = array(); - $sqlLimit = 10; + $sqlLimit = 11; // Define LIMIT 10 for lazy loading (refresh) or LIMIT 1 for client ping as new sender message - if ($loadMode === 'ping') { + if ($loadMode === CHAT_LOAD_MODE_PING) { $sqlLimit = 1; } - $dbColumnNames = $chatConfig['dbColumnNames'] ?? array(); + $dbColumnNames = $chatConfig[CHAT_DB_COLUMN_NAMES] ?? array(); // Get all dones withou threadId $sqlDoneNoThread = 'SELECT ' . implode(', ', array_map(function($column) use ($dbColumnNames) { return $dbColumnNames[$column]; - }, ['pIdCreator'])) - . ' FROM ' . $chatConfig['tableName'] . ' WHERE ' . $dbColumnNames['cIdTopic'] . ' = ? AND ' . $dbColumnNames['xId'] . ' = ? AND ' . $dbColumnNames['type'] . ' = ? ORDER BY ' . $dbColumnNames['id']; - $sqlDoneNotThreadParams = [$chatConfig['cIdTopic'], $chatConfig['xId'], 'done']; + }, [CHAT_COLUMN_PID_CREATOR])) + . ' FROM ' . $chatConfig[CHAT_TABLE_NAME] . ' WHERE ' . $dbColumnNames[CHAT_COLUMN_CID_TOPIC] . ' = ? AND ' + . $dbColumnNames[CHAT_COLUMN_XID] . ' = ? AND ' . $dbColumnNames[CHAT_COLUMN_TYPE] . ' = ? AND ' . $dbColumnNames[CHAT_COLUMN_MESSAGE] + . ' = ? ORDER BY ' . $dbColumnNames[CHAT_COLUMN_ID]; + + $sqlDoneNotThreadParams = [$chatConfig[CHAT_COLUMN_CID_TOPIC], $chatConfig[CHAT_COLUMN_XID], CHAT_COLUMN_TYPE_TAG, CHAT_TYPE_TAG_DONE]; // Get all messages from database. without threadId $sqlAllNotThread = 'SELECT ' . implode(', ', array_map(function($column) use ($dbColumnNames) { return $dbColumnNames[$column]; - }, ['id', 'xId', 'pIdCreator', 'cIdTopic', 'message', 'username', 'created'])) - . ' FROM ' . $chatConfig['tableName'] - . ' WHERE ' . $dbColumnNames['xId'] . ' = ? AND ' . $dbColumnNames['cIdTopic'] . ' = ? AND !FIND_IN_SET(' . $dbColumnNames['id'] . ', ?) AND ' . $dbColumnNames['type'] . ' = ? ' - . 'ORDER BY ' . $dbColumnNames['created'] . ' DESC LIMIT ' . $sqlLimit; - $sqlAllNotThreadParams = [$chatConfig['xId'], $chatConfig['cIdTopic'], $messageIdList, 'message']; + }, [CHAT_COLUMN_ID, CHAT_COLUMN_XID, CHAT_COLUMN_PID_CREATOR, CHAT_COLUMN_CID_TOPIC, CHAT_COLUMN_MESSAGE, CHAT_COLUMN_USERNAME, CHAT_COLUMN_CREATED])) + . ' FROM ' . $chatConfig[CHAT_TABLE_NAME] + . ' WHERE ' . $dbColumnNames[CHAT_COLUMN_XID] . ' = ? AND ' . $dbColumnNames[CHAT_COLUMN_CID_TOPIC] . ' = ? AND !FIND_IN_SET(' . $dbColumnNames[CHAT_COLUMN_ID] + . ', ?) AND ' . $dbColumnNames[CHAT_COLUMN_TYPE] . ' = ? ' + . 'ORDER BY ' . $dbColumnNames[CHAT_COLUMN_CREATED] . ' DESC LIMIT ' . $sqlLimit; + + $sqlAllNotThreadParams = [$chatConfig[CHAT_COLUMN_XID], $chatConfig[CHAT_COLUMN_CID_TOPIC], $messageIdList, CHAT_COLUMN_TYPE_MESSAGE]; $resultAllDone = $db->sql($sqlDoneNoThread, ROW_REGULAR, $sqlDoneNotThreadParams); $resultAll = $db->sql($sqlAllNotThread, ROW_REGULAR, $sqlAllNotThreadParams); + + // Get moreResults flag for client and remove the last one + $flagMoreRecords = count($resultAll) === 11; + if ($flagMoreRecords) { + array_pop($resultAll); + } + $chatJson = array(); if (!empty($resultAll)) { foreach ($resultAll as $message) { - $chatJson[$message[$dbColumnNames['id']]] = $message; + $chatJson[$message[$dbColumnNames[CHAT_COLUMN_ID]]] = $message; } } // Return last message id as value if (!empty($resultAll)) { - $columnId = array_column($resultAll, $dbColumnNames[COLUMN_ID]); - $recordId = array_column($resultAll, $dbColumnNames['xId']); - $createdDate = array_column($resultAll, $dbColumnNames[COLUMN_CREATED]); + $columnId = array_column($resultAll, $dbColumnNames[CHAT_COLUMN_ID]); + $recordId = array_column($resultAll, $dbColumnNames[CHAT_COLUMN_XID]); + $createdDate = array_column($resultAll, $dbColumnNames[CHAT_COLUMN_CREATED]); $value = max($columnId); $recordId = max($recordId); $createdDate = max($createdDate); } - $json['xId'] = $recordId ?? 0; - $json[COLUMN_CREATED] = $createdDate ?? ''; + $json[CHAT_COLUMN_XID] = $recordId ?? 0; + $json[CHAT_COLUMN_CREATED] = $createdDate ?? ''; $json[FE_TYPE_CHAT] = $chatJson; - $json['chatDone'] = $resultAllDone; - $json['pIdCreator'] = $chatConfig['pIdCreator']; - $json['dbColumnNames'] = $dbColumnNames; + $json[CHAT_DONE] = $resultAllDone; + $json[CHAT_COLUMN_PID_CREATOR] = $chatConfig[CHAT_COLUMN_PID_CREATOR]; + $json[CHAT_DB_COLUMN_NAMES] = $dbColumnNames; + $json[CHAT_FLAG_MORE_RECORDS] = $loadMode === CHAT_LOAD_MODE_PING ? null : $flagMoreRecords; return $json; } + /** Save new message given from client with given chat configuration + * + * @param $chatConfig + * @param $feMode + * @param Store $store + * @param Database $db + * @return array + * @throws \CodeException + * @throws \DbException + * @throws \UserFormException + * @throws \UserReportException + */ public static function saveChatMessage($chatConfig, $feMode, Store $store, Database $db): array { $json = array(); $chatJson = array(); - if ($feMode === FE_MODE_HIDDEN || $feMode === FE_MODE_READONLY) { + if ($feMode[API_CHAT_UPDATE_DISABLED]) { return $json; } // Get current value from client store - $value = $store::getVar('message', STORE_CLIENT, SANITIZE_ALLOW_ALLBUT); + $value = $store::getVar(CHAT_CLIENT_MESSAGE, STORE_CLIENT, SANITIZE_ALLOW_ALLBUT); - $chatConfig = Chat::createChatConfig($chatConfig, $store, $db); + $chatConfig = Chat::createChatConfig($chatConfig); $chatParams = [ - 'xId' => $chatConfig['xId'] ?? 0, - 'pIdCreator' => $chatConfig['pIdCreator'] ?? 0, - 'xGrIdStatus' => $chatConfig['xGrIdStatus'] ?? 0, - 'cIdTopic' => $chatConfig['cIdTopic'] ?? 0, - 'username' => $chatConfig['username'] ?? 0 + CHAT_COLUMN_XID => $chatConfig[CHAT_COLUMN_XID] ?? 0, + CHAT_COLUMN_PID_CREATOR => $chatConfig[CHAT_COLUMN_PID_CREATOR] ?? 0, + CHAT_COLUMN_CID_TOPIC => $chatConfig[CHAT_COLUMN_CID_TOPIC] ?? 0, + CHAT_COLUMN_USERNAME => $chatConfig[CHAT_COLUMN_USERNAME] ?? 0, + CHAT_COLUMN_X_GRID_STATUS => $chatConfig[CHAT_COLUMN_X_GRID_STATUS] ?? 0 ]; - $dbColumnNames = $chatConfig["dbColumnNames"]; + $dbColumnNames = $chatConfig[CHAT_DB_COLUMN_NAMES]; // Insert new record for chat - $sqlInsert = 'INSERT INTO ' . $chatConfig['tableName'] . '(' . $dbColumnNames["xId"] . ', '. $dbColumnNames["pIdCreator"] - . ',' . $dbColumnNames["xGrIdStatus"] . ',' . $dbColumnNames["cIdTopic"] . ',' - . $dbColumnNames["message"] . ',' . $dbColumnNames["username"] . ')' + $sqlInsert = 'INSERT INTO ' . $chatConfig[CHAT_TABLE_NAME] . '(' . $dbColumnNames[CHAT_COLUMN_XID] . ', '. $dbColumnNames[CHAT_COLUMN_PID_CREATOR] + . ',' . $dbColumnNames[CHAT_COLUMN_X_GRID_STATUS] . ',' . $dbColumnNames[CHAT_COLUMN_CID_TOPIC] . ',' + . $dbColumnNames[CHAT_COLUMN_MESSAGE] . ',' . $dbColumnNames[CHAT_COLUMN_USERNAME] . ')' . ' VALUES (?,?,?,?,?,?)'; - $insertParams = [$chatParams['xId'], $chatParams['pIdCreator'], $chatParams['xGrIdStatus'], $chatParams['cIdTopic'], $value, $chatParams['username']]; + $insertParams = [$chatParams[CHAT_COLUMN_XID], $chatParams[CHAT_COLUMN_PID_CREATOR], $chatParams[CHAT_COLUMN_X_GRID_STATUS], $chatParams[CHAT_COLUMN_CID_TOPIC], $value, $chatParams[CHAT_COLUMN_USERNAME]]; $id = $db->sql($sqlInsert, ROW_REGULAR, $insertParams); // Get inserted record data for chat json $sql = 'SELECT ' . implode(', ', array_map(function($column) use ($dbColumnNames) { return $dbColumnNames[$column]; - }, ['id', 'xId', 'pIdCreator', 'cIdTopic', 'message', 'username', 'created'])) - . ' FROM ' . $chatConfig['tableName'] - . ' WHERE ' . $dbColumnNames['id'] . ' = ?'; + }, [CHAT_COLUMN_ID, CHAT_COLUMN_XID, CHAT_COLUMN_PID_CREATOR, CHAT_COLUMN_CID_TOPIC, CHAT_COLUMN_MESSAGE, CHAT_COLUMN_USERNAME, CHAT_COLUMN_CREATED])) + . ' FROM ' . $chatConfig[CHAT_TABLE_NAME] + . ' WHERE ' . $dbColumnNames[CHAT_COLUMN_ID] . ' = ?'; $sqlParams = [$id]; $resultAll = $db->sql($sql, ROW_REGULAR, $sqlParams); if (!empty($resultAll)) { foreach ($resultAll as $message) { - $chatJson[$message[$dbColumnNames['id']]] = $message; + $chatJson[$message[$dbColumnNames[CHAT_COLUMN_ID]]] = $message; } } - $json['xId'] = $recordId ?? 0; - $json[COLUMN_CREATED] = $createdDate ?? date("Y-m-d H:i:s"); + $json[CHAT_COLUMN_XID] = $recordId ?? 0; + $json[CHAT_COLUMN_CREATED] = $createdDate ?? date("Y-m-d H:i:s"); $json[FE_TYPE_CHAT] = $chatJson; - $json['pIdCreator'] = $chatConfig['pIdCreator']; - $json['dbColumnNames'] = $chatConfig["dbColumnNames"]; + $json[CHAT_COLUMN_PID_CREATOR] = $chatConfig[CHAT_COLUMN_PID_CREATOR]; + $json[CHAT_DB_COLUMN_NAMES] = $chatConfig[CHAT_DB_COLUMN_NAMES]; + $json[CHAT_FLAG_MORE_RECORDS] = null; return $json; } + + /** Optional function to compare two different comma separated lists + * If one of the value matches, true will be returned. + * + * @param $groupList1 + * @param $groupList2 + * @return bool + */ public static function existsInGroup($groupList1, $groupList2): bool { $bool = false; @@ -300,22 +349,14 @@ class Chat implements MessageComponentInterface { return $bool; } - public static function checkAccess($groupList1, $groupList2, &$fe, &$placeholder = ''): void { - if (!empty($groupList1)) { - if (!OnString::isValidNumberList($groupList1) || !OnString::isValidNumberList($groupList2)) { - throw new \UserFormException("Invalid grIdGroupList or grIdCreatorGroupList parameter.", ERROR_INVALID_CHAT_DATA); - } - - if (!Chat::existsInGroup($groupList1, $groupList2)) { - $placeholder = 'placeholder="No access for this chat."'; - $fe[FE_MODE] = $fe[FE_MODE] === FE_MODE_HIDDEN ? $fe[FE_MODE] : FE_MODE_READONLY; - } - } - } - + /** Function to refresh xId in client info if given + * @param $from + * @param $data + * @return void + */ private function refreshRecordId($from, $data): void { - if ($this->clientInfo[$from->resourceId]['xId'] == 0 && $data->xId) { - $this->clientInfo[$from->resourceId]['xId'] = $data->xId; + if ($this->clientInfo[$from->resourceId][CHAT_COLUMN_XID] == 0 && $data->xId) { + $this->clientInfo[$from->resourceId][CHAT_COLUMN_XID] = $data->xId; } } } \ No newline at end of file diff --git a/extension/Classes/Core/QuickFormQuery.php b/extension/Classes/Core/QuickFormQuery.php index e22e673d4..939926629 100644 --- a/extension/Classes/Core/QuickFormQuery.php +++ b/extension/Classes/Core/QuickFormQuery.php @@ -2650,16 +2650,23 @@ EOF; } } - private function doChat($mode = 'chat_load'): array { + /** + * @throws \CodeException + * @throws \UserFormException + * @throws \DbException + * @throws \UserReportException + */ + private function doChat($mode = CHAT_MODE_LOAD): array { // Get currently existing messageIds from client - $messageIdList = $this->store::getVar('messageIdList', STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALL); - $loadMode = $this->store::getVar('loadMode', STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALL); - $chatConfig = $this->store::getVar('chat_config', STORE_SIP . STORE_EMPTY, SANITIZE_ALLOW_ALL); + $messageIdList = $this->store::getVar(CHAT_CLIENT_MESSAGE_ID_LIST, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALL); + $loadMode = $this->store::getVar('load_mode', STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALL); + $elementModeJson = $this->store::getVar('element_mode', STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALL); + $elementMode = json_decode($elementModeJson, true); + $chatConfig = $this->store::getVar(CHAT_CONFIG_SIP, STORE_SIP . STORE_EMPTY, SANITIZE_ALLOW_ALL); $chatConfig = json_decode($chatConfig, true); - $feMode = 'show'; - if ($mode === 'chat_save') { - return Chat::saveChatMessage($chatConfig, $feMode, $this->store, $this->dbArray[$this->dbIndexQfq]); + if ($mode === CHAT_MODE_SAVE) { + return Chat::saveChatMessage($chatConfig, $elementMode, $this->store, $this->dbArray[$this->dbIndexQfq]); } else { return Chat::createChatJson($chatConfig, $this->dbArray[$this->dbIndexQfq], $messageIdList, $loadMode); } diff --git a/extension/Classes/Core/Save.php b/extension/Classes/Core/Save.php index 44502a4fd..7e3d437c6 100644 --- a/extension/Classes/Core/Save.php +++ b/extension/Classes/Core/Save.php @@ -416,21 +416,7 @@ class Save { $formValues = $this->createEmptyTemplateGroupElements($formValues); $feColumnTypes = array(); - $feColumnTypeChat = array(); foreach ($this->feSpecNative as $fe) { - if ($fe[FE_TYPE] === FE_TYPE_CHAT && isset($formValues[$fe[FE_NAME]]) && $formValues[$fe[FE_NAME]] !== '') { - $feColumnTypeChat[$fe[FE_NAME]][FE_VALUE] = $formValues[$fe[FE_NAME]]; - - // Check for access and change fe mode dynamically - $groupList1 = isset($fe['grIdGroupList']) ? $this->evaluate->parse($fe['grIdGroupList'], ROW_REGULAR) : ''; - $groupList2 = isset($fe['grIdCreatorGroupList']) ? $this->evaluate->parse($fe['grIdCreatorGroupList'], ROW_REGULAR) : ''; - $fe['grIdGroupList'] = $groupList1; - Chat::checkAccess($groupList1, $groupList2, $fe); - - $feColumnTypeChat[$fe[FE_NAME]]['fe'] = $fe; - continue; - } - $feColumnTypes[$fe[FE_NAME]] = $fe[FE_TYPE]; } diff --git a/extension/Classes/Sql/qfqDefaultTables.sql b/extension/Classes/Sql/qfqDefaultTables.sql index c18fccfea..db4fba992 100644 --- a/extension/Classes/Sql/qfqDefaultTables.sql +++ b/extension/Classes/Sql/qfqDefaultTables.sql @@ -313,20 +313,21 @@ CREATE TABLE IF NOT EXISTS `FileUpload` # Used to save chat conversations. CREATE TABLE IF NOT EXISTS `Chat` ( - `id` INT(11) NOT NULL AUTO_INCREMENT, - `xId` INT(11) NOT NULL DEFAULT '0', - `pIdCreator` INT(11) NOT NULL DEFAULT '0', - `pIdTeam` INT(11) NOT NULL DEFAULT '0', - `cIdTopic` INT(11) NOT NULL DEFAULT '0', - `cIdThread` INT(11) NOT NULL DEFAULT '0', - `xGrIdStatus` INT(11) NOT NULL DEFAULT '0', - `type` ENUM('message','topic','done','reminder') NOT NULL DEFAULT 'message', - `done` ENUM('yes','no') NOT NULL DEFAULT 'no', - `message` TEXT NOT NULL DEFAULT '', - `reference` VARCHAR(255) NOT NULL DEFAULT '', - `username` VARCHAR(128) NOT NULL DEFAULT '', - `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `id` INT(11) NOT NULL AUTO_INCREMENT, + `xId` INT(11) NOT NULL DEFAULT '0', + `pIdCreator` INT(11) NOT NULL DEFAULT '0', + `pIdTeam` INT(11) NOT NULL DEFAULT '0', + `cIdTopic` INT(11) NOT NULL DEFAULT '0', + `cIdThread` INT(11) NOT NULL DEFAULT '0', + `cIdLastRead` INT(11) NOT NULL DEFAULT '0', + `xGrIdStatus` INT(11) NOT NULL DEFAULT '0', + `type` ENUM('message','topic','tag','reminder','read') NOT NULL DEFAULT 'message', + `message` TEXT NOT NULL DEFAULT '', + `username` VARCHAR(128) NOT NULL DEFAULT '', + `emoticon` VARCHAR(255) NOT NULL DEFAULT '', + `reference` VARCHAR(255) NOT NULL DEFAULT '', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `xId` (`xId`) ) ENGINE = InnoDB diff --git a/javascript/src/Helper/qfqChat.js b/javascript/src/Helper/qfqChat.js index e9ff7e3a4..3b31f2e48 100644 --- a/javascript/src/Helper/qfqChat.js +++ b/javascript/src/Helper/qfqChat.js @@ -62,13 +62,15 @@ QfqNS.Helper = QfqNS.Helper || {}; this.chatConfig = this.chatWindow.getAttribute("data-chat-config"); this.loadApi = this.chatWindow.getAttribute("data-load-api"); this.saveApi = this.chatWindow.getAttribute("data-save-api"); - this.sipChatElement = document.querySelector('input[name="s"]'); this.currentSearchIndex = 0; this.searchResults = []; this.lastSearchTerm = ''; this.connection = ''; this.chatRefresh = false; this.chatLoaded = false; + this.flagMoreRecords = false; + this.topBtnClicked = false; + this.isLoadingMessages = false; this.init(); } @@ -88,7 +90,12 @@ QfqNS.Helper = QfqNS.Helper || {}; if (this.topBtn) { this.topBtn.addEventListener('click', () => { + let that = this; + that.topBtnClicked = true; + this.loadNextMessages(that); + this.chatMessages.scrollTo({ top: 0, behavior: 'smooth' }); + this.checkOverflow(); }); } @@ -126,8 +133,15 @@ QfqNS.Helper = QfqNS.Helper || {}; this.chatMessages.addEventListener('scroll', () => { let that = this; + if (that.topBtnClicked) { + if (that.chatMessages.scrollTop === 0) { + that.topBtnClicked = false; + } + this.checkOverflow(); + return; + } + this.checkScrollPosition(); this.checkOverflow(); - this.loadNextMessages(that); }); this.checkOverflow(); @@ -182,11 +196,12 @@ QfqNS.Helper = QfqNS.Helper || {}; } } + // Scroll to the bottom of chat window. scrollToBottom() { - var forceReflow = this.chatMessages.offsetHeight; this.chatMessages.scrollTop = this.chatMessages.scrollHeight; } + // Handle search with given criteria. handleSearch() { let filter = this.searchInput.value; @@ -203,6 +218,7 @@ QfqNS.Helper = QfqNS.Helper || {}; this.scrollToCurrentResult(); } + // Execute the search logic. performSearch(filter) { // Implement the search logic here // Update this.searchResults based on the search @@ -256,6 +272,7 @@ QfqNS.Helper = QfqNS.Helper || {}; }, this); } + // Scroll to currently searched result scrollToCurrentResult() { if (this.searchResults.length > 0 && this.searchResults[this.currentSearchIndex]) { const selectedElement = this.searchResults[this.currentSearchIndex].element; @@ -275,16 +292,22 @@ QfqNS.Helper = QfqNS.Helper || {}; this.updateSearchInfo(); } + // Hide or show the top button checkOverflow() { - if (this.chatMessages.scrollTop > 5) { - this.topBtn.style.visibility = 'visible'; - } else { - this.topBtn.style.visibility = 'hidden'; - } + setTimeout(() => { + if (this.chatMessages.scrollTop > 5) { + this.topBtn.style.visibility = 'visible'; + } else if (this.flagMoreRecords) { + this.topBtn.style.visibility = 'visible'; + } else { + this.topBtn.style.visibility = 'hidden'; + } + }, 200); } + // Load the next 10 messages if scrolling is on top and there exists more records. loadNextMessages(that) { - if (this.chatMessages.scrollTop === 0) { + if (this.chatMessages.scrollTop === 0 && this.flagMoreRecords) { this.getMessagesFromServer(that, 'refresh'); } } @@ -299,12 +322,14 @@ QfqNS.Helper = QfqNS.Helper || {}; } } + // Reset the search filter input resetFilter(filter) { this.currentSearchIndex = 0; this.searchResults = []; this.lastSearchTerm = filter; } + // Set up the html for the individual message bubble. createNewMessagePlain(key, message, actualChatState, dbColumnNames) { let chatConfig = {}; let bubbleClass = 'chat-left-bubble'; @@ -378,6 +403,10 @@ QfqNS.Helper = QfqNS.Helper || {}; return chatContainerElement; } + // Create the html chat content with response data elements from server + // The messages will be automatically ordered and set in right place over the given id as key. + // This function is very flexible and can manage messages which aren't in right order returned from server. + // Server always gets a list from currently showing messages which allows us to response only the none existing ones. refreshChat(element, chatItems, actualChatState, dbColumnNames, load_mode) { let messageIdKey = dbColumnNames.id; @@ -431,6 +460,8 @@ QfqNS.Helper = QfqNS.Helper || {}; } } + // Load message data from server with given chat configuration. + // Result is returned as json. getMessagesFromServer(that, load_mode) { // Server request over load.php only if spinner exists // place holder !== null if ((that.chatSpinner === undefined || that.chatSpinner === null) && load_mode === 'first') { @@ -442,7 +473,7 @@ QfqNS.Helper = QfqNS.Helper || {}; let messageIdList = messageIds.join(','); messageIdList = qfqChat.htmlEncode(messageIdList); - let serializedForm = `loadMode=${load_mode}&messageIdList=${encodeURIComponent(messageIdList)}`; + let serializedForm = `load_mode=${load_mode}&messageIdList=${encodeURIComponent(messageIdList)}`; // Loading spinner only if page loads first time if (load_mode === 'first') { @@ -465,6 +496,9 @@ QfqNS.Helper = QfqNS.Helper || {}; actualState.pIdCreator = response['chat-update'].pIdCreator; actualState.created = response['chat-update'].created; actualState.chatDone = response['chat-update'].chatDone; + if (response['chat-update'].flagMoreRecords !== null) { + that.flagMoreRecords = response['chat-update'].flagMoreRecords; + } that.refreshChat(that.chatWindow, chatItems, actualState, dbColumnNames, load_mode); that.chatSpinner.style.display = 'none'; @@ -483,9 +517,29 @@ QfqNS.Helper = QfqNS.Helper || {}; xhr.send(serializedForm); } - } + // Load next 10 previous messages if scroll on top is reached + checkScrollPosition() { + // Check if the user has scrolled to the top + if (this.chatMessages.scrollTop === 0) { + let that = this; + if (this.isLoadingMessages) { + return; + } + this.isLoadingMessages = true; + let oldScrollHeight = this.chatMessages.scrollHeight; + + this.loadNextMessages(that); + setTimeout(() => { + let newScrollHeight = this.chatMessages.scrollHeight; + this.chatMessages.scrollTop += (newScrollHeight - oldScrollHeight); + this.isLoadingMessages = false; + }, 100); + } + } + } + // Set the new input state which comes over dynamic update. The only feature that is dependent on the form functionality. qfqChat.setInputState = function (element, configItem) { let inputContainer = element.nextElementSibling; let inputElement = inputContainer.querySelector(".chat-input-field"); @@ -493,21 +547,23 @@ QfqNS.Helper = QfqNS.Helper || {}; inputElement.required = configItem.required; } + // Get the disabled required properties from input element. Can be useful on server side. qfqChat.getInputState = function (inputElement) { - let feMode = []; - feMode.required = inputElement.required; - feMode.disabled = inputElement.disabled; + let elementMode = {}; + elementMode.required = inputElement.required; + elementMode.disabled = inputElement.disabled; - return JSON.stringify(feMode); + return JSON.stringify(elementMode); } + // Handle chat message submit. qfqChat.submit = function(that) { let chatInput = that.chatInput.value; chatInput = chatInput.trim(); chatInput = this.htmlEncode(chatInput); let inputState = this.getInputState(that.chatInput); - let serializedForm = `message=${encodeURIComponent(chatInput)}&feMode=${inputState}`; + let serializedForm = `message=${encodeURIComponent(chatInput)}&element_mode=${inputState}`; let submitUrl = that.saveApi; if (chatInput === ''){ @@ -532,7 +588,14 @@ QfqNS.Helper = QfqNS.Helper || {}; actualState.created = response['chat-update'].created; actualState.chatDone = response['chat-update'].chatDone; + // Set the flag only if response is not null. + if (response['chat-update'].flagMoreRecords !== null) { + that.flagMoreRecords = response['chat-update'].flagMoreRecords; + } + + // Create chat content that.refreshChat(that.chatWindow, chatItems, actualState, dbColumnNames, 'save'); + // Clear input field that.chatInput.value = ''; @@ -558,6 +621,7 @@ QfqNS.Helper = QfqNS.Helper || {}; xhr.send(serializedForm); }; + // Encode message input to prevent JS scripting attacks. qfqChat.htmlEncode = function(str) { let div = document.createElement('div'); div.textContent = str; -- GitLab