├── .gitignore ├── Makefile ├── Template ├── chat │ ├── widget.php │ ├── form.php │ └── messages.php ├── config │ └── application.php └── layout │ └── bottom.php ├── Locale ├── ru_RU │ └── translations.php ├── de_DE │ └── translations.php ├── fr_FR │ └── translations.php └── hu_HU │ └── translations.php ├── Test ├── Model │ ├── BaseModelTest.php │ ├── ChatMessageModelTest.php │ └── ChatUserModelTest.php └── PluginTest.php ├── Helper ├── ChatHelper.php └── ChatMarkdown.php ├── .travis.yml ├── Schema ├── Sqlite.php ├── Postgres.php └── Mysql.php ├── LICENSE ├── Plugin.php ├── README.md ├── Controller └── ChatController.php ├── Model ├── ChatMessageModel.php └── ChatUserModel.php └── Assets ├── chat.css └── chat.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | plugin=Chat 2 | 3 | all: 4 | @ echo "Build archive for plugin ${plugin} version=${version}" 5 | @ git archive HEAD --prefix=${plugin}/ --format=zip -o ${plugin}-${version}.zip 6 | -------------------------------------------------------------------------------- /Template/chat/widget.php: -------------------------------------------------------------------------------- 1 |
2 | render('Chat:chat/messages', array('messages' => $messages)) ?> 3 |
4 | render('Chat:chat/form') ?> -------------------------------------------------------------------------------- /Template/chat/form.php: -------------------------------------------------------------------------------- 1 |
2 | form->csrf() ?> 3 | form->text('message') ?> 4 |
-------------------------------------------------------------------------------- /Template/config/application.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | form->label(t('Refresh Interval'), 'chat_refresh_interval') ?> 4 | form->number('chat_refresh_interval', $values, $errors) ?> 5 |

6 |
-------------------------------------------------------------------------------- /Locale/ru_RU/translations.php: -------------------------------------------------------------------------------- 1 | 'Сообщений нет.', 4 | 'Refresh Interval' => 'Интервал обновления', 5 | 'Period in second (3 seconds by default)' => 'Период в секундах (по умолчанию - 3 секунды)', 6 | 'Chat' => 'Чат', 7 | 'Minimalist Chat for Kanboard.' => 'Минималистичный чат для Kanboard.', 8 | ); 9 | -------------------------------------------------------------------------------- /Test/Model/BaseModelTest.php: -------------------------------------------------------------------------------- 1 | container); 14 | $plugin->initializePlugin('Chat'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Locale/de_DE/translations.php: -------------------------------------------------------------------------------- 1 | 'Es gibt keine Nachrichten.', 4 | 'Refresh Interval' => 'Aktualisierungsintervall', 5 | 'Period in second (3 seconds by default)' => 'Zeitraum in Sekunden (3 Sekunden standardmäßig)', 6 | 'Chat' => 'Chat', 7 | 'Minimalist Chat for Kanboard.' => 'Minimalistischer Chat für Kanboard.', 8 | ); 9 | -------------------------------------------------------------------------------- /Locale/fr_FR/translations.php: -------------------------------------------------------------------------------- 1 | 'Il y a aucun message.', 5 | 'Refresh Interval' => 'Fréquence de rafraîchissement', 6 | 'Period in second (3 seconds by default)' => 'Période en seconde (3 secondes par défaut)', 7 | 'Chat' => 'Chat', 8 | 'Minimalist Chat for Kanboard.' => 'Chat minimaliste pour Kanboard.', 9 | ); 10 | -------------------------------------------------------------------------------- /Locale/hu_HU/translations.php: -------------------------------------------------------------------------------- 1 | 'Nincs üzenet.', 5 | 'Refresh Interval' => 'Frissítési időköz', 6 | 'Period in second (3 seconds by default)' => 'Időszak másodpercben (alapértelmezetten 3 másodperc)', 7 | 'Chat' => 'Csevegés', 8 | 'Minimalist Chat for Kanboard.' => 'Minimalista csevegés a Kanboard programhoz.', 9 | ); 10 | -------------------------------------------------------------------------------- /Helper/ChatHelper.php: -------------------------------------------------------------------------------- 1 | container, false); 18 | $parser->setMarkupEscaped(true); 19 | return $parser->text($text); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Test/PluginTest.php: -------------------------------------------------------------------------------- 1 | container); 12 | $this->assertNotEmpty($plugin->getPluginName()); 13 | $this->assertNotEmpty($plugin->getPluginDescription()); 14 | $this->assertNotEmpty($plugin->getPluginAuthor()); 15 | $this->assertNotEmpty($plugin->getPluginVersion()); 16 | $this->assertNotEmpty($plugin->getPluginHomepage()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Template/layout/bottom.php: -------------------------------------------------------------------------------- 1 | app->component('chat-widget', array( 2 | 'defaultTitle' => t('Chat'), 3 | 'interval' => $this->app->config('chat_refresh_interval', 3), 4 | 'lastMessageId' => $last_message_id, 5 | 'showUrl' => $this->url->to('ChatController', 'show', array('plugin' => 'Chat')), 6 | 'checkUrl' => $this->url->to('ChatController', 'check', array('plugin' => 'Chat')), 7 | 'pingUrl' => $this->url->to('ChatController', 'ping', array('plugin' => 'Chat')), 8 | 'ackUrl' => $this->url->to('ChatController', 'ack', array('plugin' => 'Chat')), 9 | )) ?> -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | sudo: false 3 | notifications: 4 | email: false 5 | 6 | php: 7 | - 7.2 8 | - 7.1 9 | - 7.0 10 | - 5.6 11 | 12 | env: 13 | global: 14 | - PLUGIN=Chat 15 | - KANBOARD_REPO=https://github.com/kanboard/kanboard.git 16 | matrix: 17 | - DB=sqlite 18 | - DB=mysql 19 | - DB=postgres 20 | 21 | matrix: 22 | fast_finish: true 23 | 24 | install: 25 | - git clone --depth 1 $KANBOARD_REPO 26 | - ln -s $TRAVIS_BUILD_DIR kanboard/plugins/$PLUGIN 27 | 28 | before_script: 29 | - cd kanboard 30 | - phpenv config-add tests/php.ini 31 | - composer install 32 | - ls -la plugins/ 33 | 34 | script: 35 | - phpunit -c tests/units.$DB.xml plugins/$PLUGIN/Test/ 36 | -------------------------------------------------------------------------------- /Schema/Sqlite.php: -------------------------------------------------------------------------------- 1 | exec('CREATE TABLE chat_messages ( 12 | "id" INTEGER PRIMARY KEY, 13 | "message" TEXT NOT NULL, 14 | "user_id" INTEGER NOT NULL, 15 | "creation_date" INTEGER NOT NULL, 16 | FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE 17 | )'); 18 | 19 | $pdo->exec('CREATE TABLE chat_users ( 20 | "user_id" INTEGER NOT NULL UNIQUE, 21 | "message_id" INTEGER DEFAULT 0, 22 | "mentioned" INTEGER DEFAULT 0, 23 | FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE 24 | )'); 25 | } 26 | -------------------------------------------------------------------------------- /Schema/Postgres.php: -------------------------------------------------------------------------------- 1 | exec('CREATE TABLE chat_messages ( 12 | "id" SERIAL PRIMARY KEY, 13 | "message" TEXT NOT NULL, 14 | "user_id" INTEGER NOT NULL, 15 | "creation_date" INTEGER NOT NULL, 16 | FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE 17 | )'); 18 | 19 | $pdo->exec('CREATE TABLE chat_users ( 20 | "user_id" INTEGER NOT NULL UNIQUE, 21 | "message_id" INTEGER DEFAULT 0, 22 | "mentioned" BOOLEAN DEFAULT \'0\', 23 | FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE 24 | )'); 25 | } 26 | -------------------------------------------------------------------------------- /Helper/ChatMarkdown.php: -------------------------------------------------------------------------------- 1 | exec('ALTER TABLE `chat_messages` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'); 12 | $pdo->exec('ALTER TABLE `chat_users` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'); 13 | } 14 | 15 | function version_1(PDO $pdo) 16 | { 17 | $pdo->exec('CREATE TABLE chat_messages ( 18 | `id` INT NOT NULL AUTO_INCREMENT, 19 | `message` TEXT NOT NULL, 20 | `user_id` INT NOT NULL, 21 | `creation_date` INT NOT NULL, 22 | FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, 23 | PRIMARY KEY(id) 24 | ) ENGINE=InnoDB CHARSET=utf8'); 25 | 26 | $pdo->exec('CREATE TABLE chat_users ( 27 | `user_id` INT NOT NULL UNIQUE, 28 | `message_id` INT DEFAULT 0, 29 | `mentioned` TINYINT(1) DEFAULT 0, 30 | FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE 31 | ) ENGINE=InnoDB CHARSET=utf8'); 32 | } 33 | -------------------------------------------------------------------------------- /Template/chat/messages.php: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | 5 | 6 |
7 | helper->avatar->small( 8 | $message['user_id'], 9 | $message['username'], 10 | $message['name'], 11 | $message['email'], 12 | $message['avatar_path'], 13 | 'avatar-left'); ?> 14 | 15 |
16 | helper->chat->markdown($message['message']) ?> 17 |
18 |
19 | helper->dt->datetime($message['creation_date']) ?> - 20 | text->e($message['username']) ?> 21 |
22 |
23 | 24 | 25 |
26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 Frédéric Guillot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Plugin.php: -------------------------------------------------------------------------------- 1 | hook->on('template:layout:js', array('template' => 'plugins/Chat/Assets/chat.js')); 14 | $this->hook->on('template:layout:css', array('template' => 'plugins/Chat/Assets/chat.css')); 15 | $this->helper->hook->attach('template:config:application', 'Chat:config/application'); 16 | 17 | $this->helper->hook->attach('template:layout:bottom', 'Chat:layout/bottom', array( 18 | 'last_message_id' => ChatMessageModel::getInstance($this->container)->getLastMessageId() 19 | )); 20 | 21 | $this->helper->register('chat', '\Kanboard\Plugin\Chat\Helper\ChatHelper'); 22 | } 23 | 24 | public function onStartup() 25 | { 26 | Translator::load($this->languageModel->getCurrentLanguage(), __DIR__.'/Locale'); 27 | } 28 | 29 | public function getClasses() 30 | { 31 | return array( 32 | 'Plugin\Chat\Model' => array( 33 | 'ChatMessageModel', 34 | 'ChatUserModel', 35 | ) 36 | ); 37 | } 38 | 39 | public function getPluginName() 40 | { 41 | return 'Chat'; 42 | } 43 | 44 | public function getPluginDescription() 45 | { 46 | return t('Minimalist Chat for Kanboard.'); 47 | } 48 | 49 | public function getPluginAuthor() 50 | { 51 | return 'Frédéric Guillot'; 52 | } 53 | 54 | public function getPluginVersion() 55 | { 56 | return '1.0.3'; 57 | } 58 | 59 | public function getPluginHomepage() 60 | { 61 | return 'https://github.com/kanboard/plugin-chat'; 62 | } 63 | 64 | public function getCompatibleVersion() 65 | { 66 | return '>=1.2.3'; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Test/Model/ChatMessageModelTest.php: -------------------------------------------------------------------------------- 1 | container); 13 | $this->assertEquals(1, $chatMessageModel->create(1, 'test')); 14 | } 15 | 16 | public function testGetLastMessageId() 17 | { 18 | $chatMessageModel = new ChatMessageModel($this->container); 19 | $this->assertSame(0, $chatMessageModel->getLastMessageId()); 20 | $this->assertEquals(1, $chatMessageModel->create(1, 'test')); 21 | $this->assertSame(1, $chatMessageModel->getLastMessageId()); 22 | } 23 | 24 | public function testHasUnseenMessages() 25 | { 26 | $chatMessageModel = new ChatMessageModel($this->container); 27 | $this->assertEquals(1, $chatMessageModel->create(1, 'test')); 28 | $this->assertTrue($chatMessageModel->hasUnseenMessages(0)); 29 | $this->assertFalse($chatMessageModel->hasUnseenMessages(1)); 30 | $this->assertFalse($chatMessageModel->hasUnseenMessages(2)); 31 | } 32 | 33 | public function testGetMessages() 34 | { 35 | $chatMessageModel = new ChatMessageModel($this->container); 36 | $chatUserModel = new ChatUserModel($this->container); 37 | 38 | $this->assertEquals(1, $chatMessageModel->create(1, 'test1')); 39 | $this->assertEquals(2, $chatMessageModel->create(1, 'test2')); 40 | 41 | $messages = $chatMessageModel->getMessages(1); 42 | 43 | $this->assertCount(2, $messages); 44 | $this->assertEquals(2, $messages[0]['id']); 45 | $this->assertEquals('test2', $messages[0]['message']); 46 | $this->assertEquals(1, $messages[1]['id']); 47 | $this->assertEquals(1, $messages[1]['user_id']); 48 | $this->assertEquals('admin', $messages[1]['username']); 49 | $this->assertEquals('', $messages[1]['name']); 50 | 51 | $this->assertEquals(2, $chatUserModel->getLastPosition(1)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Chat for Kanboard 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/kanboard/plugin-chat.svg?branch=master)](https://travis-ci.org/kanboard/plugin-chat) 5 | 6 | Minimalist internal chat for Kanboard. 7 | 8 | - Only one room for all users (small team) 9 | - No one to one chat 10 | - Notification on user mention 11 | - Simplified Markdown rendering 12 | - History of 50 visible messages 13 | - Highlight unread messages 14 | - 3 different views: minimized, normal and maximized 15 | - Auto-flush old messages in database to avoid large table 16 | 17 | This is a very basic and minimalist chat extension. 18 | 19 | The goal is **NOT** to replace full-featured traditional chat applications. 20 | If you need something more elaborated, you should probably use Slack, Mattermost, RocketChat, Jabber or IRC. 21 | 22 | Screenshots 23 | ----------- 24 | 25 | ### Normal view 26 | 27 | You can discuss with people from a small window at the bottom left: 28 | 29 | ![Normal view](https://cloud.githubusercontent.com/assets/323546/23592581/302b0d5e-01d1-11e7-96bd-ac1ff15ef0cd.png) 30 | 31 | ### Maximized View 32 | 33 | If you would like to see more messages, you can enlarge the window: 34 | 35 | ![Maximized view](https://cloud.githubusercontent.com/assets/323546/23592555/d6f51e3c-01d0-11e7-97f7-6bc8cd3c996d.png) 36 | 37 | ### Minimized View 38 | 39 | You can minimize the chat window if needed: 40 | 41 | ![Minimized view](https://cloud.githubusercontent.com/assets/323546/23592397/3775644a-01ce-11e7-8f03-a16d9f953dc9.png) 42 | 43 | ### Notification when minimized 44 | 45 | If someone mention you, the chat will blink discreetly: 46 | 47 | ![Notification](https://cloud.githubusercontent.com/assets/323546/23592372/d375f842-01cd-11e7-8730-361fa8ed8f3e.gif) 48 | 49 | Author 50 | ------ 51 | 52 | - Frédéric Guillot 53 | - License MIT 54 | 55 | Requirements 56 | ------------ 57 | 58 | - Kanboard >= 1.2.3 59 | 60 | Installation 61 | ------------ 62 | 63 | You have the choice between 3 methods: 64 | 65 | 1. Install the plugin from the Kanboard plugin manager in one click 66 | 2. Download the zip file and decompress everything under the directory `plugins/Chat` 67 | 3. Clone this repository into the folder `plugins/Chat` 68 | 69 | Note: Plugin folder is case-sensitive. 70 | 71 | Configuration 72 | ------------- 73 | 74 | From the application settings, you can adjust the chat settings: 75 | 76 | ![Settings](https://cloud.githubusercontent.com/assets/323546/23592607/956f8e88-01d1-11e7-8cbc-2c0b269fef9f.png) 77 | -------------------------------------------------------------------------------- /Controller/ChatController.php: -------------------------------------------------------------------------------- 1 | request->getValues(); 20 | 21 | if (! empty($values['message'])) { 22 | $this->chatMessageModel->create($this->userSession->getId(), $values['message']); 23 | $this->chatUserModel->createUserMentions($values['message'], $this->userSession->getUsername()); 24 | } 25 | 26 | $this->response->html($this->renderWidget()); 27 | } 28 | 29 | public function show() 30 | { 31 | $this->response->html($this->renderWidget()); 32 | } 33 | 34 | public function check() 35 | { 36 | $lastSeenMessageId = $this->request->getIntegerParam('lastMessageId'); 37 | 38 | if ($this->chatMessageModel->hasUnseenMessages($lastSeenMessageId)) { 39 | $userId = $this->userSession->getId(); 40 | 41 | $this->response->json(array( 42 | 'lastMessageId' => $this->chatMessageModel->getLastMessageId(), 43 | 'mentioned' => $this->chatUserModel->hasUserMention($userId), 44 | 'nbUnread' => $this->chatUserModel->countUnreadMessages($userId), 45 | 'messages' => $this->template->render('Chat:chat/messages', array( 46 | 'messages' => $this->chatMessageModel->getMessages($userId), 47 | )), 48 | )); 49 | } else { 50 | $this->response->status(304); 51 | } 52 | } 53 | 54 | public function ping() 55 | { 56 | $lastSeenMessageId = $this->request->getIntegerParam('lastMessageId'); 57 | 58 | if ($this->chatMessageModel->hasUnseenMessages($lastSeenMessageId)) { 59 | $userId = $this->userSession->getId(); 60 | $this->response->json(array( 61 | 'lastMessageId' => $this->chatMessageModel->getLastMessageId(), 62 | 'mentioned' => $this->chatUserModel->hasUserMention($userId), 63 | 'nbUnread' => $this->chatUserModel->countUnreadMessages($userId), 64 | )); 65 | } else { 66 | $this->response->status(304); 67 | } 68 | } 69 | 70 | public function ack() 71 | { 72 | $userId = $this->userSession->getId(); 73 | $this->response->json(array('result' => $this->chatUserModel->unsetUserMention($userId))); 74 | } 75 | 76 | protected function renderWidget() 77 | { 78 | return $this->template->render('Chat:chat/widget', array( 79 | 'messages' => $this->chatMessageModel->getMessages($this->userSession->getId()), 80 | )); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Test/Model/ChatUserModelTest.php: -------------------------------------------------------------------------------- 1 | container); 14 | $this->assertSame(0, $chatUserModel->getLastPosition(1)); 15 | $this->assertTrue($chatUserModel->setLastPosition(1, 2)); 16 | $this->assertSame(2, $chatUserModel->getLastPosition(1)); 17 | } 18 | 19 | public function testCountUnread() 20 | { 21 | $chatUserModel = new ChatUserModel($this->container); 22 | $chatMessageModel = new ChatMessageModel($this->container); 23 | $userModel = new UserModel($this->container); 24 | 25 | $this->assertNotFalse($userModel->create(array('username' => 'test'))); 26 | 27 | $this->assertSame(0, $chatUserModel->countUnreadMessages(1)); 28 | 29 | $this->assertEquals(1, $chatMessageModel->create(1, 'test1')); 30 | $this->assertEquals(2, $chatMessageModel->create(1, 'test2')); 31 | 32 | $this->assertSame(0, $chatUserModel->countUnreadMessages(1)); 33 | $this->assertSame(2, $chatUserModel->countUnreadMessages(2)); 34 | 35 | $this->assertTrue($chatUserModel->setLastPosition(2, 1)); 36 | $this->assertSame(1, $chatUserModel->countUnreadMessages(2)); 37 | 38 | $this->assertTrue($chatUserModel->setLastPosition(2, 2)); 39 | $this->assertSame(0, $chatUserModel->countUnreadMessages(2)); 40 | } 41 | 42 | public function testSetUserMention() 43 | { 44 | $chatUserModel = new ChatUserModel($this->container); 45 | $this->assertTrue($chatUserModel->setUserMention('admin')); 46 | $this->assertFalse($chatUserModel->setUserMention('notfound')); 47 | } 48 | 49 | public function testGetMentionedUsers() 50 | { 51 | $chatUserModel = new ChatUserModel($this->container); 52 | 53 | $users = $chatUserModel->getMentionedUsers('@admin this is a message'); 54 | $this->assertEquals(array('admin'), $users); 55 | 56 | $users = $chatUserModel->getMentionedUsers('Hey @firstname.lastname this is a message'); 57 | $this->assertEquals(array('firstname.lastname'), $users); 58 | 59 | $users = $chatUserModel->getMentionedUsers('Hey @firstname.lastname, this is a message for @admin'); 60 | $this->assertEquals(array('firstname.lastname', 'admin'), $users); 61 | } 62 | 63 | public function testCreateUserMentions() 64 | { 65 | $chatUserModel = new ChatUserModel($this->container); 66 | $chatUserModel->createUserMentions('Hey @firstname.lastname, this is a message for @admin', 'foobar'); 67 | 68 | $this->assertTrue($chatUserModel->hasUserMention(1)); 69 | $this->assertFalse($chatUserModel->hasUserMention(2)); 70 | 71 | $this->assertTrue($chatUserModel->unsetUserMention(1)); 72 | $this->assertFalse($chatUserModel->hasUserMention(1)); 73 | } 74 | 75 | public function testCreateUserMentionsWithSelfMention() 76 | { 77 | $chatUserModel = new ChatUserModel($this->container); 78 | $chatUserModel->createUserMentions('Hey @firstname.lastname, this is a message for @admin', 'admin'); 79 | 80 | $this->assertFalse($chatUserModel->hasUserMention(1)); 81 | $this->assertFalse($chatUserModel->hasUserMention(2)); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Model/ChatMessageModel.php: -------------------------------------------------------------------------------- 1 | db->table(self::TABLE)->persist(array( 31 | 'user_id' => $userId, 32 | 'message' => $message, 33 | 'creation_date' => time(), 34 | )); 35 | 36 | if ($messageId > 0) { 37 | $this->chatUserModel->setLastPosition($userId, $messageId); 38 | } 39 | 40 | $this->cleanup(self::MAX_MESSAGES); 41 | 42 | return $messageId; 43 | } 44 | 45 | /** 46 | * Get last messages for a given user 47 | * 48 | * @param integer $userId 49 | * @param integer $limit 50 | * @return array 51 | */ 52 | public function getMessages($userId, $limit = 50) 53 | { 54 | $position = $this->chatUserModel->getLastPosition($userId); 55 | $records = $this->db->table(self::TABLE) 56 | ->columns( 57 | self::TABLE.'.id', 58 | self::TABLE.'.creation_date', 59 | self::TABLE.'.message', 60 | self::TABLE.'.user_id', 61 | UserModel::TABLE.'.username', 62 | UserModel::TABLE.'.name', 63 | UserModel::TABLE.'.email', 64 | UserModel::TABLE.'.avatar_path' 65 | ) 66 | ->join(UserModel::TABLE, 'id', 'user_id') 67 | ->desc(self::TABLE.'.id') 68 | ->limit($limit) 69 | ->findAll(); 70 | 71 | foreach ($records as &$record) { 72 | $record['unread'] = $record['id'] > $position; 73 | } 74 | 75 | if (count($records) > 0) { 76 | $this->chatUserModel->setLastPosition($userId, $records[0]['id']); 77 | } 78 | 79 | asort($records); 80 | return $records; 81 | } 82 | 83 | /** 84 | * Check if the user has unseen messages 85 | * 86 | * @param integer $messageId 87 | * @return bool 88 | */ 89 | public function hasUnseenMessages($messageId) 90 | { 91 | return $this->db->table(self::TABLE)->gt('id', $messageId)->count() > 0; 92 | } 93 | 94 | /** 95 | * Get last messageID from the database 96 | * 97 | * @return integer 98 | */ 99 | public function getLastMessageId() 100 | { 101 | return (int) $this->db->table(self::TABLE)->desc('id')->findOneColumn('id'); 102 | } 103 | 104 | /** 105 | * Remove old messages to avoid large table 106 | * 107 | * @param integer $max 108 | */ 109 | public function cleanup($max) 110 | { 111 | $total = $this->db->table(self::TABLE)->count(); 112 | 113 | if ($total > $max) { 114 | $ids = $this->db->table(self::TABLE)->asc('id')->limit($total - $max)->findAllByColumn('id'); 115 | 116 | if (! empty($ids)) { 117 | $this->db->table(self::TABLE)->in('id', $ids)->remove(); 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Model/ChatUserModel.php: -------------------------------------------------------------------------------- 1 | db->table(self::TABLE)->eq('user_id', $userId)->findOneColumn('message_id') ?: 0; 26 | } 27 | 28 | /** 29 | * Set read position for a user 30 | * 31 | * @param int $userId 32 | * @param int $messageId 33 | * @return bool 34 | */ 35 | public function setLastPosition($userId, $messageId) 36 | { 37 | $this->create($userId); 38 | 39 | return $this->db->table(self::TABLE) 40 | ->eq('user_id', $userId) 41 | ->update(array('message_id' => $messageId)); 42 | } 43 | 44 | /** 45 | * Check if the given user is mentioned 46 | * 47 | * @param int $userId 48 | * @return bool 49 | */ 50 | public function hasUserMention($userId) 51 | { 52 | return $this->db->table(self::TABLE)->eq('user_id', $userId)->findOneColumn('mentioned') == 1; 53 | } 54 | 55 | /** 56 | * Acknowledge a mentioned user 57 | * 58 | * @param int $userId 59 | * @return bool 60 | */ 61 | public function unsetUserMention($userId) 62 | { 63 | return $this->db->table(self::TABLE) 64 | ->eq('user_id', $userId) 65 | ->update(array('mentioned' => 0)); 66 | } 67 | 68 | /** 69 | * Set mention flag if username exists 70 | * 71 | * @param string $username 72 | * @return bool 73 | */ 74 | public function setUserMention($username) 75 | { 76 | $userId = $this->userModel->getIdByUsername($username); 77 | 78 | if ($userId > 0) { 79 | $this->create($userId); 80 | 81 | return $this->db->table(self::TABLE) 82 | ->eq('user_id', $userId) 83 | ->update(array('mentioned' => 1)); 84 | } 85 | 86 | return false; 87 | } 88 | 89 | /** 90 | * Parse message to get mentioned users 91 | * 92 | * @param string $message 93 | * @return array 94 | */ 95 | public function getMentionedUsers($message) 96 | { 97 | $users = array(); 98 | 99 | if (preg_match_all('/@([^\s,!:?]+)/', $message, $matches)) { 100 | array_walk($matches[1], function (&$username) { $username = rtrim($username, '.'); }); 101 | $users = array_unique($matches[1]); 102 | } 103 | 104 | return $users; 105 | } 106 | 107 | /** 108 | * Parse message and ignore mention for connected user 109 | * 110 | * @param string $message 111 | * @param string $currentUserName 112 | */ 113 | public function createUserMentions($message, $currentUserName) 114 | { 115 | $users = $this->getMentionedUsers($message); 116 | 117 | foreach ($users as $username) { 118 | if ($currentUserName !== $username) { 119 | $this->setUserMention($username); 120 | } 121 | } 122 | } 123 | 124 | /** 125 | * Get unread counter for a given user 126 | * 127 | * @param int $userId 128 | * @return int 129 | */ 130 | public function countUnreadMessages($userId) 131 | { 132 | $position = $this->getLastPosition($userId); 133 | return $this->db->table(ChatMessageModel::TABLE)->gt('id', $position)->count(); 134 | } 135 | 136 | /** 137 | * Create user record if missing 138 | * 139 | * @param int $userId 140 | */ 141 | public function create($userId) 142 | { 143 | if (! $this->db->table(self::TABLE)->eq('user_id', $userId)->exists()) { 144 | $this->db->table(self::TABLE)->insert(array('user_id' => $userId)); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Assets/chat.css: -------------------------------------------------------------------------------- 1 | #chat-form input[type='text'] { 2 | width: 295px; 3 | max-width: 99%; 4 | } 5 | 6 | .chat-widget-toolbar { 7 | position: absolute; 8 | top: 0; 9 | right: 0; 10 | font-size: 0.8em; 11 | z-index: 999; 12 | } 13 | 14 | .chat-widget-toolbar a { 15 | opacity: 0.3; 16 | margin-left: 10px; 17 | } 18 | 19 | .chat-widget-toolbar a:hover { 20 | opacity: 1.0; 21 | } 22 | 23 | .chat-widget-normal, .chat-widget-minimized, .chat-widget-maximized { 24 | position: fixed; 25 | bottom: 0; 26 | right: 30px; 27 | padding: 2px; 28 | border: 1px solid #888; 29 | } 30 | 31 | .chat-widget-normal, .chat-widget-maximized { 32 | background: white; 33 | box-shadow: 3px 0 5px 0 rgba(173,173,173, 0.8); 34 | } 35 | 36 | .chat-widget-minimized { 37 | width: 300px; 38 | height: 18px; 39 | background: #efefef; 40 | } 41 | 42 | .chat-widget-minimized a { 43 | text-decoration: none; 44 | color: #333; 45 | } 46 | 47 | .chat-widget-minimized a:hover { 48 | color: #999; 49 | } 50 | 51 | .chat-widget-normal { 52 | width: 300px; 53 | height: 300px; 54 | } 55 | 56 | .chat-widget-maximized { 57 | width: 800px; 58 | height: 450px; 59 | } 60 | 61 | .chat-widget-mentioned { 62 | -webkit-animation: backgroundBlink 1s linear infinite; 63 | -moz-animation: backgroundBlink 1s linear infinite; 64 | animation: backgroundBlink 1s linear infinite; 65 | } 66 | 67 | #chat-form input.chat-input-mentioned { 68 | -webkit-animation: borderBlink 1s linear infinite; 69 | -moz-animation: borderBlink 1s linear infinite; 70 | animation: borderBlink 1s linear infinite; 71 | } 72 | 73 | #chat-widget-messages-container { 74 | min-height: 270px; 75 | position: relative; 76 | } 77 | 78 | .chat-widget-maximized #chat-widget-messages-container { 79 | min-height: 420px; 80 | } 81 | 82 | .chat-messages { 83 | -ms-overflow-style: none; 84 | overflow: scroll; 85 | position: absolute; 86 | width: 99%; 87 | bottom: 0; 88 | left: 0; 89 | } 90 | 91 | .chat-widget-normal .chat-messages { 92 | max-height: 265px; 93 | } 94 | 95 | .chat-widget-maximized .chat-messages { 96 | max-height: 415px; 97 | } 98 | 99 | .chat-widget-maximized #chat-form input[type='text'] { 100 | width: 795px; 101 | } 102 | 103 | .chat-message { 104 | margin-bottom: 5px; 105 | } 106 | 107 | .chat-message.unread { 108 | -moz-animation: fadeIt 5s ease-in-out; 109 | -webkit-animation: fadeIt 5s ease-in-out; 110 | animation: fadeIt 5s ease-in-out; 111 | } 112 | 113 | .chat-message-body { 114 | font-size: 0.9em; 115 | } 116 | 117 | .chat-message-info { 118 | color: #aaa; 119 | font-size: 0.7em; 120 | font-weight: 300; 121 | margin-left: 30px; 122 | clear: both; 123 | } 124 | 125 | @keyframes fadeIt { 126 | 0% { background-color: #FFFFFF; } 127 | 50% { background-color: #ffeb8e; } 128 | 100% { background-color: #FFF8DC; } 129 | } 130 | 131 | @keyframes backgroundBlink { 132 | 0% { background-color: #efefef; } 133 | 50% { background-color: #ffeb8e; } 134 | 100% { background-color: #FFF8DC; } 135 | } 136 | 137 | @keyframes borderBlink { 138 | 0% { border-color: #ccc; } 139 | 50% { border-color: #000; } 140 | } 141 | 142 | @media (max-device-width: 667px) { 143 | .chat-widget-normal, .chat-widget-minimized, .chat-widget-maximized { 144 | right: 5px; 145 | } 146 | 147 | .chat-widget-normal, .chat-widget-maximized { 148 | width: 300px; 149 | height: 300px; 150 | } 151 | 152 | .chat-widget-maximized #chat-widget-messages-container { 153 | min-height: 270px; 154 | } 155 | 156 | .chat-widget-maximized .chat-messages { 157 | max-height: 265px; 158 | } 159 | 160 | #chat-form input[type='text'] { 161 | width: 280px; 162 | } 163 | } 164 | 165 | @media (max-device-width: 667px) and (orientation: landscape) { 166 | .chat-widget-normal, .chat-widget-maximized { 167 | width: 500px; 168 | height: 250px; 169 | } 170 | 171 | .chat-widget-normal #chat-widget-messages-container, 172 | .chat-widget-maximized #chat-widget-messages-container { 173 | min-height: 215px; 174 | } 175 | 176 | .chat-widget-normal .chat-messages, 177 | .chat-widget-maximized .chat-messages { 178 | max-height: 200px; 179 | } 180 | 181 | #chat-form input[type='text'] { 182 | width: 480px; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Assets/chat.js: -------------------------------------------------------------------------------- 1 | KB.component('chat-widget', function (containerElement, options) { 2 | var widgetElement; 3 | var widgetState = localStorage.getItem('chatState') || 'minimized'; 4 | var lastMessageId = options.lastMessageId; 5 | var nbUnread = 0; 6 | var mentioned = false; 7 | var originalTitle = document.title; 8 | var messagesBuffer = []; 9 | var bufferPosition = -1; 10 | 11 | function onKeyDown(e) { 12 | var key = KB.utils.getKey(e); 13 | 14 | if (messagesBuffer.length > 0 && (key === 'ArrowUp' || key === 'ArrowDown')) { 15 | var buffer = messagesBuffer.slice().reverse(); 16 | 17 | if (key === 'ArrowUp') { 18 | bufferPosition++; 19 | } else if (key === 'ArrowDown') { 20 | bufferPosition--; 21 | } 22 | 23 | if (bufferPosition >= buffer.length) { 24 | bufferPosition = 0; 25 | } else if (bufferPosition < 0) { 26 | bufferPosition = buffer.length - 1; 27 | } 28 | 29 | e.target.value = buffer[bufferPosition]; 30 | } 31 | } 32 | 33 | function unsetUserMention() { 34 | if (mentioned) { 35 | mentioned = false; 36 | KB.http.get(options.ackUrl); 37 | KB.dom(getTextInputElement()).removeClass('chat-input-mentioned'); 38 | } 39 | } 40 | 41 | function setState(state) { 42 | widgetState = state; 43 | nbUnread = 0; 44 | localStorage.setItem('chatState', state); 45 | } 46 | 47 | function minimize() { 48 | setState('minimized'); 49 | updateWidget(); 50 | } 51 | 52 | function maximize() { 53 | setState('maximized'); 54 | KB.http.get(options.showUrl).success(updateWidget); 55 | } 56 | 57 | function restore() { 58 | if (widgetState === 'minimized') { 59 | unsetUserMention(); 60 | } 61 | 62 | setState('normal'); 63 | KB.http.get(options.showUrl).success(updateWidget); 64 | } 65 | 66 | function listen() { 67 | var formElement = KB.find('#chat-form'); 68 | formElement.on('submit', onFormSubmit, false); 69 | } 70 | 71 | function refresh() { 72 | var url = options.checkUrl; 73 | 74 | if (widgetState === 'minimized') { 75 | url = options.pingUrl; 76 | } 77 | 78 | KB.http.get(url + "&lastMessageId=" + lastMessageId).success(function (response) { 79 | var isDifferentState = response.nbUnread !== nbUnread || response.mentioned !== mentioned; 80 | nbUnread = response.nbUnread; 81 | mentioned = response.mentioned; 82 | 83 | if (widgetState !== 'minimized') { 84 | lastMessageId = response.lastMessageId; 85 | updateMessages(response.messages); 86 | 87 | if (mentioned) { 88 | KB.dom(getTextInputElement()).addClass('chat-input-mentioned'); 89 | setTimeout(unsetUserMention, 10000); 90 | } 91 | } else if (isDifferentState) { 92 | updateWidget(); 93 | } 94 | }); 95 | } 96 | 97 | function getTextInputElement() { 98 | return document.querySelector('#chat-form input[type="text"]'); 99 | } 100 | 101 | function onFormSubmit() { 102 | var formElement = KB.find('#chat-form').build(); 103 | var url = formElement.getAttribute('action'); 104 | 105 | bufferPosition = -1; 106 | messagesBuffer.push(getTextInputElement().value); 107 | 108 | if (messagesBuffer.length > 5) { 109 | messagesBuffer = messagesBuffer.slice(-5); 110 | } 111 | 112 | if (url) { 113 | KB.http.postForm(url, formElement).success(function (response) { 114 | updateWidget(response); 115 | getTextInputElement().focus(); 116 | }); 117 | } 118 | } 119 | 120 | function updateMessages(html) { 121 | KB.find('#chat-widget-messages-container').html(html); 122 | scrollBottom(); 123 | } 124 | 125 | function createMinimizedWidget() { 126 | var toolbar = KB.dom('div').addClass('chat-widget-toolbar'); 127 | var title = options.defaultTitle; 128 | 129 | if (nbUnread > 0) { 130 | title = '(' + nbUnread + ') ' + title; 131 | } 132 | 133 | toolbar.add(KB.dom('a') 134 | .attr('href', '#') 135 | .html('') 136 | .click(restore) 137 | .build()); 138 | 139 | var widget = KB.dom('div') 140 | .addClass('chat-widget-minimized') 141 | .add(toolbar.build()) 142 | .add(KB.dom('a').attr('href', '#').click(restore).text(title).build()); 143 | 144 | if (mentioned) { 145 | widget.addClass('chat-widget-mentioned'); 146 | } 147 | 148 | return widget.build(); 149 | } 150 | 151 | function createMaximizedWidget(html) { 152 | var toolbar = KB.dom('div').addClass('chat-widget-toolbar'); 153 | 154 | toolbar.add(KB.dom('a') 155 | .attr('href', '#') 156 | .html('') 157 | .click(minimize) 158 | .build()); 159 | 160 | toolbar.add(KB.dom('a') 161 | .attr('href', '#') 162 | .html('') 163 | .click(restore) 164 | .build()); 165 | 166 | var containerElement = KB.dom('div') 167 | .html(html) 168 | .build(); 169 | 170 | return KB.dom('div') 171 | .addClass('chat-widget-maximized') 172 | .add(toolbar.build()) 173 | .add(containerElement) 174 | .build(); 175 | } 176 | 177 | function createNormalWidget(html) { 178 | var toolbar = KB.dom('div').addClass('chat-widget-toolbar'); 179 | 180 | toolbar.add(KB.dom('a') 181 | .attr('href', '#') 182 | .html('') 183 | .click(minimize) 184 | .build()); 185 | 186 | toolbar.add(KB.dom('a') 187 | .attr('href', '#') 188 | .html('') 189 | .click(maximize) 190 | .build()); 191 | 192 | var containerElement = KB.dom('div') 193 | .html(html) 194 | .build(); 195 | 196 | return KB.dom('div') 197 | .addClass('chat-widget-normal') 198 | .add(toolbar.build()) 199 | .add(containerElement) 200 | .build(); 201 | } 202 | 203 | function createWidget(html) { 204 | updateWindowTitle(); 205 | 206 | if (widgetState === 'minimized') { 207 | return createMinimizedWidget(html); 208 | } else if (widgetState === 'maximized') { 209 | return createMaximizedWidget(html); 210 | } 211 | 212 | return createNormalWidget(html); 213 | } 214 | 215 | function updateWidget(html) { 216 | var updatedWidgetElement = createWidget(html); 217 | 218 | KB.dom(widgetElement).replace(updatedWidgetElement); 219 | widgetElement = updatedWidgetElement; 220 | 221 | if (widgetState !== 'minimized') { 222 | listen(); 223 | scrollBottom(); 224 | 225 | var textInputElement = getTextInputElement(); 226 | textInputElement.onfocus = unsetUserMention; 227 | textInputElement.onkeydown = onKeyDown; 228 | } 229 | } 230 | 231 | function renderWidget(html) { 232 | widgetElement = createWidget(html); 233 | containerElement.appendChild(widgetElement); 234 | 235 | if (widgetState !== 'minimized') { 236 | listen(); 237 | scrollBottom(); 238 | 239 | var textInputElement = getTextInputElement(); 240 | textInputElement.onfocus = unsetUserMention; 241 | textInputElement.onkeydown = onKeyDown; 242 | } 243 | 244 | setInterval(refresh, options.interval * 1000); 245 | } 246 | 247 | function scrollBottom() { 248 | var lastMessageElement = document.querySelector('.chat-message:last-child'); 249 | 250 | if (lastMessageElement) { 251 | document.querySelector('.chat-messages').scrollTop = lastMessageElement.offsetTop; 252 | } 253 | } 254 | 255 | function updateWindowTitle() { 256 | if (nbUnread > 0) { 257 | if (mentioned) { 258 | document.title = '(' + nbUnread + '!) ' + originalTitle; 259 | } else { 260 | document.title = '(' + nbUnread + ') ' + originalTitle; 261 | } 262 | } else if (mentioned) { 263 | document.title = '(*) ' + originalTitle; 264 | } else if (document.title !== originalTitle) { 265 | document.title = originalTitle; 266 | } 267 | } 268 | 269 | this.render = function () { 270 | KB.http.get(options.showUrl).success(renderWidget); 271 | }; 272 | }); 273 | --------------------------------------------------------------------------------