From 34d18493650057ed45d70cd0d326671fecb96183 Mon Sep 17 00:00:00 2001 From: Daniel Brendel Date: Sun, 15 Oct 2023 15:32:05 +0200 Subject: [PATCH] Chat feature --- README.md | 4 +- app/config/routes.php | 3 + app/controller/index.php | 85 +++++++++++++++++++++++++ app/lang/de/app.php | 6 +- app/lang/en/app.php | 6 +- app/migrations/ChatMsgModel.php | 47 ++++++++++++++ app/migrations/ChatViewModel.php | 47 ++++++++++++++ app/migrations/UserModel.php | 1 + app/models/ChatMsgModel.php | 103 +++++++++++++++++++++++++++++++ app/models/ChatViewModel.php | 40 ++++++++++++ app/models/UserModel.php | 19 ++++++ app/resources/js/app.js | 43 +++++++++++++ app/resources/sass/app.scss | 83 +++++++++++++++++++++++++ app/views/chat.php | 53 ++++++++++++++++ app/views/layout.php | 7 ++- app/views/navbar.php | 14 +++++ public/js/app.js | 2 +- 17 files changed, 557 insertions(+), 6 deletions(-) create mode 100644 app/migrations/ChatMsgModel.php create mode 100644 app/migrations/ChatViewModel.php create mode 100644 app/models/ChatMsgModel.php create mode 100644 app/models/ChatViewModel.php create mode 100644 app/views/chat.php diff --git a/README.md b/README.md index ecbc64c..80ba37e 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Released under the MIT license HortusFox is a self-hosted collaborative plant management system which you can use in your own environment to manage all your plants. You can add your plants with various details and photos and assign them to a location of your environment. There is a dashboard available that shows all important overview information. The system does also feature a warning system in order to indicate -which plants need special care, user authentication, tasks and a history log of what actions users have taken. The system features -collaborative management, so you can manage your plants with multiple users. +which plants need special care, user authentication, tasks, inventory management, collaborative chat and a history log of what actions +users have taken. The system features collaborative management, so you can manage your plants with multiple users. ## System requirements - PHP ^8.2 diff --git a/app/config/routes.php b/app/config/routes.php index 47fa4e0..ecd10ed 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -44,5 +44,8 @@ return [ array('/inventory/group/add', 'POST', 'index@add_inventory_group_item'), array('/inventory/group/edit', 'POST', 'index@edit_inventory_group_item'), array('/inventory/group/remove', 'ANY', 'index@remove_inventory_group_item'), + array('/chat', 'GET', 'index@view_chat'), + array('/chat/add', 'POST', 'index@add_chat_message'), + array('/chat/query', 'GET', 'index@query_chat_messages'), array('$404', 'ANY', 'error404@index') ]; diff --git a/app/controller/index.php b/app/controller/index.php index 8317d17..6beeb05 100644 --- a/app/controller/index.php +++ b/app/controller/index.php @@ -790,4 +790,89 @@ class IndexController extends BaseController { ]); } } + + /** + * Handles URL: /chat + * + * @param Asatru\Controller\ControllerArg $request + * @return Asatru\View\ViewHandler + */ + public function view_chat($request) + { + $user = UserModel::getAuthUser(); + + $messages = ChatMsgModel::getChat(); + + return parent::view(['content', 'chat'], [ + 'user' => $user, + 'messages' => $messages, + '_refresh_chat' => true + ]); + } + + /** + * Handles URL: /chat/add + * + * @param Asatru\Controller\ControllerArg $request + * @return Asatru\View\RedirectHandler + */ + public function add_chat_message($request) + { + $validator = new Asatru\Controller\PostValidator([ + 'message' => 'required' + ]); + + if (!$validator->isValid()) { + $errorstr = ''; + foreach ($validator->errorMsgs() as $err) { + $errorstr .= $err . '
'; + } + + FlashMessage::setMsg('error', 'Invalid data given:
' . $errorstr); + + return back(); + } + + $message = $request->params()->query('message', null); + + ChatMsgModel::addMessage($message); + + return redirect('/chat'); + } + + /** + * Handles URL: /chat/query + * + * @param Asatru\Controller\ControllerArg $request + * @return Asatru\View\JsonHandler + */ + public function query_chat_messages($request) + { + try { + $result = []; + + $messages = ChatMsgModel::getLatestMessages(); + + foreach ($messages as $message) { + $result[] = [ + 'id' => $message->get('id'), + 'userId' => $message->get('userId'), + 'userName' => UserModel::getNameById($message->get('userId')), + 'message' => $message->get('message'), + 'created_at' => $message->get('created_at'), + 'diffForHumans' => (new Carbon($message->get('created_at')))->diffForHumans(), + ]; + } + + return json([ + 'code' => 200, + 'messages' => $result + ]); + } catch (\Exception $e) { + return json([ + 'code' => 500, + 'msg' => $e->getMessage() + ]); + } + } } diff --git a/app/lang/de/app.php b/app/lang/de/app.php index bc13e3a..ca778c3 100644 --- a/app/lang/de/app.php +++ b/app/lang/de/app.php @@ -122,5 +122,9 @@ return [ 'edit_inventory_item' => 'Eintrag bearbeiten', 'manage_groups' => 'Gruppen verwalten', 'token' => 'Token', - 'close' => 'Schließen' + 'close' => 'Schließen', + 'chat' => 'Chat', + 'chat_hint' => 'Hier können Nachrichten ausgetauscht werden', + 'send' => 'Senden', + 'new' => 'Neu' ]; \ No newline at end of file diff --git a/app/lang/en/app.php b/app/lang/en/app.php index 374fc85..d903bf9 100644 --- a/app/lang/en/app.php +++ b/app/lang/en/app.php @@ -122,5 +122,9 @@ return [ 'edit_inventory_item' => 'Edit inventory item', 'manage_groups' => 'Manage groups', 'token' => 'Token', - 'close' => 'Close' + 'close' => 'Close', + 'chat' => 'Chat', + 'chat_hint' => 'This is the group chat of the workspace', + 'send' => 'send', + 'new' => 'New' ]; \ No newline at end of file diff --git a/app/migrations/ChatMsgModel.php b/app/migrations/ChatMsgModel.php new file mode 100644 index 0000000..9fb91d5 --- /dev/null +++ b/app/migrations/ChatMsgModel.php @@ -0,0 +1,47 @@ +connection = $pdo; + } + + /** + * Called when the table shall be created or modified + * + * @return void + */ + public function up() + { + $this->database = new Asatru\Database\Migration('chatmsg', $this->connection); + $this->database->drop(); + $this->database->add('id INT NOT NULL AUTO_INCREMENT PRIMARY KEY'); + $this->database->add('userId INT NOT NULL'); + $this->database->add('message TEXT NOT NULL'); + $this->database->add('created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP'); + $this->database->create(); + } + + /** + * Called when the table shall be dropped + * + * @return void + */ + public function down() + { + if ($this->database) + $this->database->drop(); + } +} \ No newline at end of file diff --git a/app/migrations/ChatViewModel.php b/app/migrations/ChatViewModel.php new file mode 100644 index 0000000..cf3afbb --- /dev/null +++ b/app/migrations/ChatViewModel.php @@ -0,0 +1,47 @@ +connection = $pdo; + } + + /** + * Called when the table shall be created or modified + * + * @return void + */ + public function up() + { + $this->database = new Asatru\Database\Migration('chatview', $this->connection); + $this->database->drop(); + $this->database->add('id INT NOT NULL AUTO_INCREMENT PRIMARY KEY'); + $this->database->add('userId INT NOT NULL'); + $this->database->add('messageId INT NOT NULL'); + $this->database->add('created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP'); + $this->database->create(); + } + + /** + * Called when the table shall be dropped + * + * @return void + */ + public function down() + { + if ($this->database) + $this->database->drop(); + } +} \ No newline at end of file diff --git a/app/migrations/UserModel.php b/app/migrations/UserModel.php index e207e23..b12fbf1 100644 --- a/app/migrations/UserModel.php +++ b/app/migrations/UserModel.php @@ -33,6 +33,7 @@ class UserModel_Migration { $this->database->add('token VARCHAR(1024) NOT NULL'); $this->database->add('lang VARCHAR(512) NULL'); $this->database->add('show_log BOOLEAN NOT NULL DEFAULT 1'); + $this->database->add('last_seen_msg INT NULL'); $this->database->add('created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP'); $this->database->create(); } diff --git a/app/models/ChatMsgModel.php b/app/models/ChatMsgModel.php new file mode 100644 index 0000000..91b7d2d --- /dev/null +++ b/app/models/ChatMsgModel.php @@ -0,0 +1,103 @@ +get('id'), $message + ]); + } catch (\Exception $e) { + throw $e; + } + } + + /** + * @param $limit + * @return mixed + * @throws \Exception + */ + public static function getChat($limit = 50) + { + try { + $result = static::raw('SELECT * FROM `' . self::tableName() . '` ORDER BY created_at DESC LIMIT ' . $limit); + + UserModel::updateLastSeenMsg($result->get(0)->get('id')); + + return $result; + } catch (\Exception $e) { + throw $e; + } + } + + /** + * @return mixed + * @throws \Exception + */ + public static function getLatestMessages() + { + try { + $user = UserModel::getAuthUser(); + if (!$user) { + throw new \Exception('Invalid user'); + } + + $result = static::raw('SELECT * FROM `' . self::tableName() . '` WHERE id > ? ORDER BY created_at DESC', [($user->get('last_seen_msg')) ? $user->get('last_seen_msg') : 0]); + + if (($result) && (count($result) > 0)) { + UserModel::updateLastSeenMsg($result->get(0)->get('id')); + } + + return $result; + } catch (\Exception $e) { + throw $e; + } + } + + /** + * @return int + * @throws \Exception + */ + public static function getUnreadCount() + { + try { + $user = UserModel::getAuthUser(); + if (!$user) { + throw new \Exception('Invalid user'); + } + + $data = static::raw('SELECT COUNT(*) AS `count` FROM `' . self::tableName() . '` WHERE userId <> ? AND id > ? ORDER BY id ASC', [ + $user->get('id'), ($user->get('last_seen_msg')) ? $user->get('last_seen_msg') : 0 + ])->first(); + + return $data->get('count'); + } catch (\Exception $e) { + throw $e; + } + } + + /** + * Return the associated table name of the migration + * + * @return string + */ + public static function tableName() + { + return 'chatmsg'; + } +} \ No newline at end of file diff --git a/app/models/ChatViewModel.php b/app/models/ChatViewModel.php new file mode 100644 index 0000000..bf1f1be --- /dev/null +++ b/app/models/ChatViewModel.php @@ -0,0 +1,40 @@ +first(); + if (!$row) { + static::raw('INSERT INTO `' . self::tableName() . '` (userId, messageId) VALUES(?, ?)', [ + $userId, $messageId + ]); + + return true; + } + + return false; + } catch (\Exception $e) { + throw $e; + } + } + + /** + * Return the associated table name of the migration + * + * @return string + */ + public static function tableName() + { + return 'chatview'; + } +} \ No newline at end of file diff --git a/app/models/UserModel.php b/app/models/UserModel.php index 1bfb402..8e8952b 100644 --- a/app/models/UserModel.php +++ b/app/models/UserModel.php @@ -91,6 +91,25 @@ class UserModel extends \Asatru\Database\Model { } } + /** + * @param $id + * @return void + * @throws \Exception + */ + public static function updateLastSeenMsg($id) + { + try { + $user = static::getAuthUser(); + if (!$user) { + throw new \Exception('User not authenticated'); + } + + static::raw('UPDATE `' . self::tableName() . '` SET last_seen_msg = ? WHERE id = ?', [$id, $user->get('id')]); + } catch (\Exception $e) { + throw $e; + } + } + /** * Return the associated table name of the migration * diff --git a/app/resources/js/app.js b/app/resources/js/app.js index 5b56e3b..9a31680 100644 --- a/app/resources/js/app.js +++ b/app/resources/js/app.js @@ -9,6 +9,8 @@ import './../sass/app.scss'; window.axios = require('axios'); window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; +window.constChatMessageQueryRefreshRate = 1000 * 15; + window.vue = new Vue({ el: '#app', @@ -36,6 +38,7 @@ window.vue = new Vue({ confirmPlantRemoval: 'Are you sure you want to remove this plant?', confirmSetAllWatered: 'Are you sure you want to update the last watered date of all these plants?', confirmInventoryItemRemoval: 'Are you sure you want to remove this item?', + newChatMessage: 'New' }, methods: { @@ -343,5 +346,45 @@ window.vue = new Vue({ } }); }, + + refreshChat: function(auth_user) + { + window.vue.ajaxRequest('get', window.location.origin + '/chat/query', {}, function(response) { + if (response.code == 200) { + response.messages.forEach(function(elem, index) { + document.getElementById('chat').innerHTML = window.vue.renderNewChatMessage(elem, auth_user) + document.getElementById('chat').innerHTML; + }); + } + }); + + setTimeout(window.vue.refreshChat, window.constChatMessageQueryRefreshRate); + }, + + renderNewChatMessage: function(elem, auth_user) + { + let chatmsgright = ''; + if (elem.userId == auth_user) { + chatmsgright = 'chat-message-right'; + } + + let html = ` +
+
+
` + elem.userName + `
+
` + window.vue.newChatMessage + `
+
+ +
+
` + elem.message + `
+
+ +
+ ` + elem.diffForHumans + ` +
+
+ `; + + return html; + }, } }); \ No newline at end of file diff --git a/app/resources/sass/app.scss b/app/resources/sass/app.scss index 89d2b1f..6775cde 100644 --- a/app/resources/sass/app.scss +++ b/app/resources/sass/app.scss @@ -93,6 +93,10 @@ h2 { text-decoration: underline; } +.is-stretched { + width: 100%; +} + .float-right { float: right; } @@ -146,6 +150,35 @@ a.navbar-burger:hover { } } +.notification-badge { + color: rgb(255, 255, 255); + text-decoration: none; + border-radius: 2px; +} + +.notification-badge .notify-badge { + @media screen and (min-width: 1089px) { + position: absolute; + right: -5px; + top: 4px; + } + @media screen and (max-width: 1087px) { + position: relative; + right: -4px; + top: -10px; + } + padding: 1px 7px; + border-radius: 50%; + background: rgb(255, 0, 0); + color: rgb(255, 255, 255); + font-size: 0.8em; +} + +.notify-badge .notify-badge-count { + position: relative; + top: -2px; +} + .locations { text-align: center; } @@ -843,6 +876,56 @@ a.navbar-burger:hover { text-decoration: underline; } +.chat-message { + position: relative; + width: 90%; + padding: 15px; + margin-bottom: 20px; + background-color: rgba(200, 200, 200, 0.5); + border-radius: 10px; +} + +.chat-message-right { + margin-left: 10%; + background-color: rgba(115, 143, 100, 0.9); +} + +.chat-message-user { + position: relative; + font-size: 1.2em; + margin-bottom: 10px; + color: rgb(123, 193, 223); +} + +.chat-message-new { + position: relative; + display: inline-block; + background-color: rgb(212, 130, 67); + border: 1px solid rgb(92, 64, 25); + color: rgb(250, 250, 250); + border-radius: 4px; + padding: 5px; + font-size: 0.5em; + text-transform: uppercase; + float: right; +} + +.chat-message-content { + position: relative; +} + +.chat-message-content pre { + background-color: transparent; + color: rgb(255, 255, 255); +} + +.chat-message-info { + position: relative; + margin-top: 10px; + font-size: 0.76em; + color: rgb(155, 155, 155); +} + .scroll-to-top { position: fixed; z-index: 3; diff --git a/app/views/chat.php b/app/views/chat.php new file mode 100644 index 0000000..335404c --- /dev/null +++ b/app/views/chat.php @@ -0,0 +1,53 @@ +
+
+ +
+
+

{{ __('app.chat') }}

+ +

{{ __('app.chat_hint') }}

+ + @include('flashmsg.php') + +
+
+ @csrf + +
+
+ +
+ +
+
+
+ + @if (isset($messages)) +
+ @foreach ($messages as $message) +
+
+
{{ UserModel::getNameById($message->get('userId')) }}
+ @if (ChatViewModel::handleNewMessage($user->get('id'), $message->get('id'))) +
{{ __('app.new') }}
+ @endif +
+ +
+
{{ $message->get('message') }}
+
+ +
+ {{ (new Carbon($message->get('created_at')))->diffForHumans() }} +
+
+ @endforeach +
+ @endif +
+
+ +
+
\ No newline at end of file diff --git a/app/views/layout.php b/app/views/layout.php index 59c7d4a..950da2b 100644 --- a/app/views/layout.php +++ b/app/views/layout.php @@ -2,7 +2,7 @@ - + {{ __('app.workspace_title', ['name' => env('APP_WORKSPACE')]) }} @@ -623,6 +623,7 @@ window.vue.confirmPlantRemoval = '{{ __('app.confirmPlantRemoval') }}'; window.vue.confirmSetAllWatered = '{{ __('app.confirmSetAllWatered') }}'; window.vue.confirmInventoryItemRemoval = '{{ __('app.confirmInventoryItemRemoval') }}'; + window.vue.newChatMessage = '{{ __('app.new') }}'; window.vue.initNavBar(); @@ -636,6 +637,10 @@ @if (isset($_expand_inventory_item)) window.vue.expandInventoryItem('inventory-item-body-{{ $_expand_inventory_item }}'); @endif + + @if ((isset($_refresh_chat)) && ($_refresh_chat === true)) + window.vue.refreshChat({{ $user->get('id') }}); + @endif }); diff --git a/app/views/navbar.php b/app/views/navbar.php index fc5b4ba..5429269 100644 --- a/app/views/navbar.php +++ b/app/views/navbar.php @@ -39,6 +39,20 @@ + +