├── roave-bc-check.yaml ├── src ├── Config │ └── Routes.php ├── Models │ ├── ParticipantModel.php │ ├── ConversationModel.php │ └── MessageModel.php ├── Views │ ├── message.php │ ├── conversation.php │ └── javascript.php ├── Helpers │ └── chat_helper.php ├── Entities │ ├── Message.php │ ├── Conversation.php │ └── Participant.php ├── Controllers │ └── Messages.php └── Database │ └── Migrations │ └── 2020-02-04-111617_create_chat_tables.php ├── infection.json.dist ├── psalm_autoload.php ├── .php-cs-fixer.dist.php ├── composer-unused.php ├── psalm.xml ├── UPGRADING.md ├── LICENSE ├── SECURITY.md ├── composer.json ├── README.md ├── depfile.yaml ├── deptrac.yaml └── rector.php /roave-bc-check.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - '#\[BC\] SKIPPED: .+ could not be found in the located source#' 4 | -------------------------------------------------------------------------------- /src/Config/Routes.php: -------------------------------------------------------------------------------- 1 | group('chatapi', ['namespace' => '\Tatter\Chat\Controllers'], static function ($routes) { 9 | $routes->resource('messages', ['websafe' => 1]); 10 | }); 11 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src/" 5 | ], 6 | "excludes": [ 7 | "Config", 8 | "Database/Migrations", 9 | "Views" 10 | ] 11 | }, 12 | "logs": { 13 | "text": "build/infection.log" 14 | }, 15 | "mutators": { 16 | "@default": true 17 | }, 18 | "bootstrap": "vendor/codeigniter4/framework/system/Test/bootstrap.php" 19 | } 20 | -------------------------------------------------------------------------------- /psalm_autoload.php: -------------------------------------------------------------------------------- 1 | files() 9 | ->in([ 10 | __DIR__ . '/src/', 11 | __DIR__ . '/tests/', 12 | ]) 13 | ->exclude('build') 14 | ->append([__FILE__]); 15 | 16 | $overrides = []; 17 | 18 | $options = [ 19 | 'finder' => $finder, 20 | 'cacheFile' => 'build/.php-cs-fixer.cache', 21 | ]; 22 | 23 | return Factory::create(new CodeIgniter4(), $overrides, $options)->forProjects(); 24 | -------------------------------------------------------------------------------- /composer-unused.php: -------------------------------------------------------------------------------- 1 | $config 11 | ->addNamedFilter(NamedFilter::fromString('tatter/frontend')) 12 | ->setAdditionalFilesFor('codeigniter4/framework', [ 13 | ...Glob::glob(__DIR__ . '/vendor/codeigniter4/framework/system/Helpers/*.php'), 14 | ]); 15 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Models/ConversationModel.php: -------------------------------------------------------------------------------- 1 | 'required']; 19 | 20 | /** 21 | * Faked data for Fabricator. 22 | */ 23 | public function fake(Generator &$faker): Conversation 24 | { 25 | return new Conversation([ 26 | 'title' => $faker->company, 27 | 'uid' => implode('_', $faker->words), 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Version 2 to 3 4 | *** 5 | 6 | * **Chat** is now officially a Tatter Module, so relies on the opinionated frontend tech provided by `Tatter\Frontend` 7 | * Assets have been integrated into view files; any page that displays a conversation should include the Chat JavaScript, e.g.: 8 | ```html 9 | 12 | ``` 13 | 14 | ## Version 1 to 2 15 | *** 16 | 17 | * There is no longer a config file, so remove any extensions (e.g. **app/Config/Chat.php**). 18 | * Corollary, there is no longer a "silent" mode. Wrap chat interactions in a `try..catch` block if you suspect failures. 19 | * Instead of a session key provide an implementation of `codeigniter4/authentication-implementation` for determining the current user (see [User Guide](https://codeigniter4.github.io/CodeIgniter4/extending/authentication.html)). 20 | * Corollary, the library assumes the required function is pre-loaded (e.g. call `helper('auth')` before using `Chat`) 21 | * Entity stored relations are now private members and will not be available in `$attributes` - extending classes should use the relation getter 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 Tatter Software 4 | Copyright (c) 2020 Matthew Gatner 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/Models/MessageModel.php: -------------------------------------------------------------------------------- 1 | builder() 28 | ->select('chat_messages.*, chat_participants.updated_at') 29 | ->join( 30 | 'chat_participants', 31 | 'chat_messages.conversation_id = chat_participants.conversation_id AND user_id = ' . $userId 32 | ) 33 | ->where('chat_messages.created_at > chat_participants.updated_at') 34 | ->get()->getCustomResultObject($this->returnType); 35 | 36 | return $result ?? []; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | The development team and community take all security issues seriously. **Please do not make public any uncovered flaws.** 4 | 5 | ## Reporting a Vulnerability 6 | 7 | Thank you for improving the security of our code! Any assistance in removing security flaws will be acknowledged. 8 | 9 | **Please report security flaws by emailing the development team directly: support@tattersoftware.com**. 10 | 11 | The lead maintainer will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating 12 | the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the 13 | progress towards a fix and full announcement, and may ask for additional information or guidance. 14 | 15 | ## Disclosure Policy 16 | 17 | When the security team receives a security bug report, they will assign it to a primary handler. 18 | This person will coordinate the fix and release process, involving the following steps: 19 | 20 | - Confirm the problem and determine the affected versions. 21 | - Audit code to find any potential similar problems. 22 | - Prepare fixes for all releases still under maintenance. These fixes will be released as fast as possible. 23 | 24 | ## Comments on this Policy 25 | 26 | If you have suggestions on how this process could be improved please submit a Pull Request. 27 | -------------------------------------------------------------------------------- /src/Views/message.php: -------------------------------------------------------------------------------- 1 | 2 | participant->user_id === user_id()): ?> 3 | 4 |
5 | getContent('markdown') ?> 20 | participant->username, 0, 2) ?> 34 |
35 | 36 | 37 | 38 |
39 | participant->username, 0, 2) ?> 53 | getContent('markdown') ?> 68 |
69 | 70 | 71 | -------------------------------------------------------------------------------- /src/Views/conversation.php: -------------------------------------------------------------------------------- 1 |
2 |
name ?? 'Chat' ?>
3 | 4 |
5 |
6 | 7 | messages as $message): ?> 8 | 9 | created_at->format('n/j/Y')): ?> 10 | created_at->format('n/j/Y'); ?> 11 |
12 |

13 |
14 |

15 |
16 | 17 | 18 | $message]) ?> 19 | 20 | 21 |
22 |
23 | 24 | 44 | 45 | 59 |
60 | -------------------------------------------------------------------------------- /src/Helpers/chat_helper.php: -------------------------------------------------------------------------------- 1 | You must be logged in to chat!

'; 24 | } 25 | 26 | // If no UID was passed then generate one 27 | if ($uid === null) { 28 | $uid = bin2hex(random_bytes(16)); 29 | } 30 | 31 | // Check for an existing conversation 32 | $conversations = new ConversationModel(); 33 | if (! $conversation = $conversations->where('uid', $uid)->first()) { 34 | // Create a new conversation 35 | $data = [ 36 | 'uid' => $uid, 37 | 'title' => $title ?? 'Chat', 38 | ]; 39 | 40 | $id = $conversations->insert($data); 41 | $conversation = $conversations->find($id); 42 | } 43 | 44 | // If a title was specified then use it over the database version 45 | if ($title) { 46 | $conversation->title = $title; 47 | } 48 | 49 | // Add/update the user 50 | $participant = $conversation->addUser($userId); 51 | 52 | return view('Tatter\Chat\Views\conversation', ['conversation' => $conversation]); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Entities/Message.php: -------------------------------------------------------------------------------- 1 | 'int', 14 | 'participant_id' => 'int', 15 | ]; 16 | 17 | /** 18 | * Initial default values 19 | */ 20 | protected $attributes = [ 21 | 'content' => '', 22 | ]; 23 | 24 | /** 25 | * Stored copy of the sending Participant. 26 | */ 27 | private ?Participant $participant = null; 28 | 29 | /** 30 | * Returns the message content with optional formatting. 31 | * 32 | * @param mixed $format 33 | */ 34 | public function getContent($format = 'html'): string 35 | { 36 | switch ($format) { 37 | case 'html': 38 | return nl2br(strip_tags($this->attributes['content'])); 39 | 40 | case 'json': 41 | return json_encode($this->attributes['content']); 42 | 43 | case 'markdown': 44 | return (new GithubFlavoredMarkdownConverter([ 45 | 'html_input' => 'strip', 46 | 'allow_unsafe_links' => false, 47 | ]))->convert($this->attributes['content']); 48 | 49 | case 'raw': 50 | default: 51 | return $this->attributes['content']; 52 | } 53 | } 54 | 55 | /** 56 | * Injects the Participant. Used to eager load 57 | * batches of Messages. 58 | */ 59 | public function setParticipant(?Participant $participant = null): void 60 | { 61 | $this->participant = $participant; 62 | } 63 | 64 | /** 65 | * Loads and returns the participant who sent this message. 66 | * Ideally this is already injected by the Conversation. 67 | * 68 | * @return Participant 69 | */ 70 | protected function getParticipant(): ?Participant 71 | { 72 | if ($this->participant === null) { 73 | $this->participant = model(ParticipantModel::class)->find($this->attributes['participant_id']); 74 | } 75 | 76 | return $this->participant; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tatter/chat", 3 | "description": "Embedded chat widget for CodeIgniter 4", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "codeigniter", 8 | "codeigniter4", 9 | "chat" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Matthew Gatner", 14 | "email": "mgatner@tattersoftware.com", 15 | "homepage": "https://tattersoftware.com", 16 | "role": "Developer" 17 | } 18 | ], 19 | "homepage": "https://github.com/tattersoftware/codeigniter4-chat", 20 | "require": { 21 | "php": "^7.4 || ^8.0", 22 | "codeigniter4/authentication-implementation": "1.0", 23 | "tatter/frontend": "^1.0", 24 | "tatter/users": "^1.0" 25 | }, 26 | "require-dev": { 27 | "codeigniter4/framework": "^4.1", 28 | "tatter/imposter": "^1.0", 29 | "tatter/tools": "^2.0" 30 | }, 31 | "minimum-stability": "dev", 32 | "prefer-stable": true, 33 | "autoload": { 34 | "psr-4": { 35 | "Tatter\\Chat\\": "src" 36 | }, 37 | "exclude-from-classmap": [ 38 | "**/Database/Migrations/**" 39 | ] 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tests\\Support\\": "tests/_support" 44 | } 45 | }, 46 | "config": { 47 | "allow-plugins": { 48 | "ergebnis/composer-normalize": true, 49 | "phpstan/extension-installer": true 50 | } 51 | }, 52 | "scripts": { 53 | "analyze": [ 54 | "phpstan analyze", 55 | "psalm", 56 | "rector process --dry-run" 57 | ], 58 | "ci": [ 59 | "Composer\\Config::disableProcessTimeout", 60 | "@deduplicate", 61 | "@analyze", 62 | "@composer normalize --dry-run", 63 | "@test", 64 | "@inspect", 65 | "@style" 66 | ], 67 | "deduplicate": "phpcpd app/ src/", 68 | "inspect": "deptrac analyze --cache-file=build/deptrac.cache", 69 | "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", 70 | "retool": "retool", 71 | "style": "php-cs-fixer fix --verbose --ansi --using-cache=no", 72 | "test": "phpunit" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Controllers/Messages.php: -------------------------------------------------------------------------------- 1 | request->getPost('conversation')) { 24 | log_message('error', 'Conversation ID missing for Messages::create()'); 25 | 26 | return null; 27 | } 28 | 29 | // Get the conversation 30 | $conversation = model(ConversationModel::class)->find($conversationId); 31 | if ($conversation === null) { 32 | log_message('error', 'Unable to locate conversation # ' . $conversationId); 33 | 34 | return null; 35 | } 36 | 37 | // Verify authentication 38 | if (! function_exists('user_id')) { 39 | throw new RuntimeException('Authentication system failure'); 40 | } 41 | 42 | // Get the current user 43 | if (! $userId = user_id()) { 44 | log_message('error', 'Unable to determine the current user'); 45 | 46 | return null; 47 | } 48 | 49 | // Get or create the participant 50 | if (! $participant = $conversation->addUser($userId)) { 51 | log_message('error', 'Could not add participant to conversation # ' . $conversation->id); 52 | 53 | return null; 54 | } 55 | 56 | // Say it 57 | if (! $messageId = $participant->say($this->request->getPost('content'))) { 58 | log_message('error', 'Failed to add content to conversation # ' . $conversation->id); 59 | 60 | return null; 61 | } 62 | 63 | // Respond with the pre-formatted message to display 64 | return $this->respondCreated(view('Tatter\Chat\Views\message', [ 65 | 'message' => $this->model->find($messageId), 66 | ]), 'message created'); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Views/javascript.php: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function() { 2 | 3 | // Auto-resize the textarea 4 | // Code from Stephan Wagner; https://stephanwagner.me/auto-resizing-textarea-with-vanilla-javascript 5 | document.querySelectorAll("[data-autoresize]").forEach(function (element) { 6 | 7 | element.style.boxSizing = "border-box"; 8 | var offset = element.offsetHeight - element.clientHeight; 9 | 10 | document.addEventListener("input", function (event) { 11 | event.target.style.height = "auto"; 12 | event.target.style.height = event.target.scrollHeight + offset + "px"; 13 | }); 14 | 15 | element.addEventListener("keypress", submitOnEnter); 16 | element.removeAttribute("data-autoresize"); 17 | }); 18 | 19 | // Initialize Bootstrap tooltips 20 | $('[data-toggle="tooltip"]').tooltip(); 21 | 22 | // Scroll chat message wrappers to the bottom 23 | $(".chat-conversation .card-body").scrollTop(10000); 24 | }); 25 | 26 | // Code from Dimitar Nestorov; https://stackoverflow.com/questions/8934088/how-to-make-enter-key-in-a-textarea-submit-a-form 27 | function submitOnEnter(event) { 28 | if (event.which === 13 && ! event.shiftKey) { 29 | event.target.form.dispatchEvent(new Event("submit", {cancelable: true})); 30 | event.preventDefault(); // Prevents the addition of a new line in the text field (not needed in a lot of cases) 31 | } 32 | } 33 | 34 | // Handle submitting messages via AJAX 35 | function sendMessage(formElement) { 36 | const url = ""; 37 | const data = new URLSearchParams(new FormData(formElement)); 38 | 39 | fetch(url, { 40 | method: "post", 41 | body: data, 42 | headers: { "X-Requested-With": "XMLHttpRequest" } 43 | }) 44 | .then((response) => { 45 | if (response.status == 201) { 46 | // Reset the input 47 | formElement.content.value = ""; 48 | 49 | // Add the message to the display 50 | return response.text(); 51 | } else { 52 | return '

Unable to send your message

'; 53 | } 54 | }) 55 | .then((text) => { 56 | // Add the pre-formatted message to the display 57 | var target = "conversation-" + formElement.conversation.value; 58 | document.getElementById(target).insertAdjacentHTML("beforeend", text); 59 | 60 | // Scroll the display 61 | $(".chat-conversation .card-body").scrollTop(10000); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /src/Database/Migrations/2020-02-04-111617_create_chat_tables.php: -------------------------------------------------------------------------------- 1 | ['type' => 'varchar', 'constraint' => 255], 17 | 'uid' => ['type' => 'varchar', 'constraint' => 255], 18 | 'private' => ['type' => 'boolean', 'default' => 0], 19 | 'direct' => ['type' => 'boolean', 'default' => 0], 20 | 'created_at' => ['type' => 'datetime', 'null' => true], 21 | 'updated_at' => ['type' => 'datetime', 'null' => true], 22 | 'deleted_at' => ['type' => 'datetime', 'null' => true], 23 | ]; 24 | 25 | $this->forge->addField('id'); 26 | $this->forge->addField($fields); 27 | 28 | $this->forge->addUniqueKey('uid'); 29 | $this->forge->addKey(['deleted_at', 'id']); 30 | $this->forge->addKey('created_at'); 31 | 32 | $this->forge->createTable('chat_conversations'); 33 | 34 | // Participants 35 | $fields = [ 36 | 'conversation_id' => ['type' => 'int', 'unsigned' => true], 37 | 'user_id' => ['type' => 'int', 'unsigned' => true], 38 | 'created_at' => ['type' => 'datetime', 'null' => true], 39 | 'updated_at' => ['type' => 'datetime', 'null' => true], 40 | 'deleted_at' => ['type' => 'datetime', 'null' => true], 41 | ]; 42 | 43 | $this->forge->addField('id'); 44 | $this->forge->addField($fields); 45 | 46 | $this->forge->addKey(['conversation_id', 'user_id']); 47 | $this->forge->addKey(['user_id', 'conversation_id']); 48 | $this->forge->addKey('updated_at'); 49 | 50 | $this->forge->createTable('chat_participants'); 51 | 52 | // Messages 53 | $fields = [ 54 | 'conversation_id' => ['type' => 'int', 'unsigned' => true], 55 | 'participant_id' => ['type' => 'int', 'unsigned' => true], 56 | 'content' => ['type' => 'text'], 57 | 'created_at' => ['type' => 'datetime', 'null' => true], 58 | 'updated_at' => ['type' => 'datetime', 'null' => true], 59 | 'deleted_at' => ['type' => 'datetime', 'null' => true], 60 | ]; 61 | 62 | $this->forge->addField('id'); 63 | $this->forge->addField($fields); 64 | 65 | $this->forge->addKey(['conversation_id', 'created_at']); 66 | $this->forge->addKey(['created_at', 'conversation_id']); 67 | 68 | $this->forge->createTable('chat_messages'); 69 | } 70 | 71 | /** 72 | * @return void 73 | */ 74 | public function down() 75 | { 76 | $this->forge->dropTable('chat_conversations'); 77 | $this->forge->dropTable('chat_participants'); 78 | $this->forge->dropTable('chat_messages'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Entities/Conversation.php: -------------------------------------------------------------------------------- 1 | 'bool', 15 | 'direct' => 'bool', 16 | ]; 17 | 18 | /** 19 | * Gets the participants for this conversation 20 | * 21 | * @return array of Participants 22 | */ 23 | public function getParticipants(): array 24 | { 25 | return model(ParticipantModel::class) 26 | ->where('conversation_id', $this->attributes['id']) 27 | ->orderBy('created_at', 'asc') 28 | ->findAll(); 29 | } 30 | 31 | /** 32 | * Gets the messages for this conversation. 33 | * Preloads the Participant for each message. 34 | * 35 | * @return Message[] 36 | */ 37 | public function getMessages(): array 38 | { 39 | // Get the builder from the message model 40 | $builder = model(MessageModel::class)->builder(); 41 | 42 | $rows = $builder 43 | ->select('chat_messages.*, chat_participants.user_id') 44 | ->join('chat_participants', 'chat_messages.participant_id = chat_participants.id', 'left') 45 | ->where('chat_messages.conversation_id', $this->attributes['id']) 46 | ->orderBy('chat_messages.created_at', 'asc') 47 | ->get()->getResultArray(); 48 | 49 | if (empty($rows)) { 50 | return []; 51 | } 52 | 53 | // Create the Message and Participant entities from each row 54 | $messages = []; 55 | 56 | foreach ($rows as $row) { 57 | $participant = new Participant([ 58 | 'id' => $row['participant_id'], 59 | 'conversation_id' => $row['conversation_id'], 60 | 'user_id' => $row['user_id'], 61 | ]); 62 | 63 | unset($row['user_id']); 64 | 65 | $message = new Message($row); 66 | $message->setParticipant($participant); 67 | 68 | $messages[] = $message; 69 | } 70 | 71 | return $messages; 72 | } 73 | 74 | /** 75 | * Adds a user to this conversation. 76 | */ 77 | public function addUser(int $userId): ?Participant 78 | { 79 | // Build the row 80 | $row = [ 81 | 'conversation_id' => $this->attributes['id'], 82 | 'user_id' => $userId, 83 | ]; 84 | 85 | // Check for an existing participant 86 | if ($participant = model(ParticipantModel::class)->where($row)->first()) { 87 | // Bump the last active date and return the entity 88 | return $participant->active(); 89 | } 90 | 91 | // Create the new participant 92 | if ($id = model(ParticipantModel::class)->insert($row)) { 93 | return model(ParticipantModel::class)->find($id); 94 | } 95 | 96 | // Something went wrong 97 | $error = "Unable to add user {$userId} to conversation: " . $this->attributes['id']; 98 | log_message('error', $error); 99 | 100 | throw new RuntimeException($error); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tatter\Chat 2 | 3 | Embedded chat widget for CodeIgniter 4 4 | 5 | [![](https://github.com/tattersoftware/codeigniter4-chat/workflows/PHPUnit/badge.svg)](https://github.com/tattersoftware/codeigniter4-chat/actions/workflows/phpunit.yml) 6 | [![](https://github.com/tattersoftware/codeigniter4-chat/workflows/PHPStan/badge.svg)](https://github.com/tattersoftware/codeigniter4-chat/actions/workflows/phpstan.yml) 7 | [![](https://github.com/tattersoftware/codeigniter4-chat/workflows/Deptrac/badge.svg)](https://github.com/tattersoftware/codeigniter4-chat/actions/workflows/deptrac.yml) 8 | [![Coverage Status](https://coveralls.io/repos/github/tattersoftware/codeigniter4-chat/badge.svg?branch=develop)](https://coveralls.io/github/tattersoftware/codeigniter4-chat?branch=develop) 9 | 10 | ## Quick Start 11 | 12 | 1. Install with Composer: `> composer require tatter/chat` 13 | 2. Update the database: `> php spark migrate --all` 14 | 3. Publish asset files: `> php spark publish` 15 | 4. Add Chat JS to your layout: `` 16 | 4. Add a chat to any view: `` 17 | 18 | ## Features 19 | 20 | **Chat** allows developers to add a lightweight Bootstrap-style chat client to any page. 21 | 22 | ## Installation 23 | 24 | Install easily via Composer to take advantage of CodeIgniter 4's autoloading capabilities 25 | and always be up-to-date: 26 | ```shell 27 | > composer require tatter/chat 28 | ``` 29 | 30 | Or, install manually by downloading the source files and adding the directory to 31 | **app/Config/Autoload.php**. 32 | 33 | Once the files are downloaded and included in the autoload, run any library migrations 34 | to ensure the database is setup correctly: 35 | ```shell 36 | > php spark migrate --all 37 | ``` 38 | 39 | ### Assets 40 | 41 | **Chat** has JavaScript code as well as asset dependencies that need to be included 42 | with any view that has a conversation on it. Assets are managed by the 43 | [Tatter\Assets](https://github.com/tattersoftware/codeigniter4-assets) library; you can 44 | publish all files with CodeIgniter's Publisher: `spark publish`. Be sure to configure 45 | the **Assets** filter and apply it to routes (see docs). 46 | 47 | ### Authentication 48 | 49 | **Chat** uses `Tatter\Users` to determine participants username and display name. You must 50 | be sure to include a package that provides `codeigniter4/authentication-implementation` 51 | (like **Shield**) or make your own (see [Authentication](https://codeigniter4.github.io/CodeIgniter4/extending/authentication.html) 52 | for framework requirements). 53 | 54 | ## Usage 55 | 56 | The easiest way to start a chat is with the helper. Load the helper file (`helper('chat')`) 57 | and then use the `chat($uid, $title)` command wherever you would use a partial view: 58 | 59 | ```html 60 |
61 |

Yellow Widgets

62 |

Main product info here!

63 | 64 | 67 | ... 68 | ``` 69 | 70 | The parameters to `chat()` are optional, and excluding them will load a one-time chat with 71 | a random UID (e.g. for a one-time site visitor). 72 | 73 | ## Extending 74 | 75 | Conversations are stored and loaded from the database with the `ConversationModel`, and 76 | most of the logic is handled by the Entities. For example, a `Conversation` entity can 77 | `$conversation->addUser($userId)` to join or refresh a user and get back a `Participant`. 78 | A `Participant` can `$participant->say('hello world')` to add a `Message`. 79 | -------------------------------------------------------------------------------- /depfile.yaml: -------------------------------------------------------------------------------- 1 | paths: 2 | - ./src/ 3 | - ./vendor/codeigniter4/framework/system/ 4 | - ./vendor/tatter/ 5 | exclude_files: 6 | - '#.*test.*#i' 7 | layers: 8 | - name: Model 9 | collectors: 10 | - type: bool 11 | must: 12 | - type: className 13 | regex: .*[A-Za-z]+Model$ 14 | must_not: 15 | - type: directory 16 | regex: vendor/.* 17 | - name: Vendor Model 18 | collectors: 19 | - type: bool 20 | must: 21 | - type: className 22 | regex: .*[A-Za-z]+Model$ 23 | - type: directory 24 | regex: vendor/.* 25 | - name: Controller 26 | collectors: 27 | - type: bool 28 | must: 29 | - type: className 30 | regex: .*\/Controllers\/.* 31 | must_not: 32 | - type: directory 33 | regex: vendor/.* 34 | - name: Vendor Controller 35 | collectors: 36 | - type: bool 37 | must: 38 | - type: className 39 | regex: .*\/Controllers\/.* 40 | - type: directory 41 | regex: vendor/.* 42 | - name: Config 43 | collectors: 44 | - type: bool 45 | must: 46 | - type: directory 47 | regex: src/Config/.* 48 | must_not: 49 | - type: className 50 | regex: .*Services 51 | - type: directory 52 | regex: vendor/.* 53 | - name: Vendor Config 54 | collectors: 55 | - type: bool 56 | must: 57 | - type: directory 58 | regex: vendor/.*/Config/.* 59 | must_not: 60 | - type: className 61 | regex: .*Services 62 | - name: Entity 63 | collectors: 64 | - type: bool 65 | must: 66 | - type: directory 67 | regex: src/Entities/.* 68 | must_not: 69 | - type: directory 70 | regex: vendor/.* 71 | - name: Vendor Entity 72 | collectors: 73 | - type: bool 74 | must: 75 | - type: directory 76 | regex: vendor/.*/Entities/.* 77 | - name: View 78 | collectors: 79 | - type: bool 80 | must: 81 | - type: directory 82 | regex: src/Views/.* 83 | must_not: 84 | - type: directory 85 | regex: vendor/.* 86 | - name: Vendor View 87 | collectors: 88 | - type: bool 89 | must: 90 | - type: directory 91 | regex: vendor/.*/Views/.* 92 | - name: Service 93 | collectors: 94 | - type: className 95 | regex: .*Services.* 96 | ruleset: 97 | Entity: 98 | - Config 99 | - Model 100 | - Service 101 | - Vendor Config 102 | - Vendor Entity 103 | - Vendor Model 104 | Config: 105 | - Service 106 | - Vendor Config 107 | Model: 108 | - Config 109 | - Entity 110 | - Service 111 | - Vendor Config 112 | - Vendor Entity 113 | - Vendor Model 114 | Service: 115 | - Config 116 | - Vendor Config 117 | 118 | # Ignore anything in the Vendor layers 119 | Vendor Model: 120 | - Config 121 | - Service 122 | - Vendor Config 123 | - Vendor Controller 124 | - Vendor Entity 125 | - Vendor Model 126 | - Vendor View 127 | Vendor Controller: 128 | - Service 129 | - Vendor Config 130 | - Vendor Controller 131 | - Vendor Entity 132 | - Vendor Model 133 | - Vendor View 134 | Vendor Config: 135 | - Config 136 | - Service 137 | - Vendor Config 138 | - Vendor Controller 139 | - Vendor Entity 140 | - Vendor Model 141 | - Vendor View 142 | Vendor Entity: 143 | - Service 144 | - Vendor Config 145 | - Vendor Controller 146 | - Vendor Entity 147 | - Vendor Model 148 | - Vendor View 149 | Vendor View: 150 | - Service 151 | - Vendor Config 152 | - Vendor Controller 153 | - Vendor Entity 154 | - Vendor Model 155 | - Vendor View 156 | skip_violations: 157 | -------------------------------------------------------------------------------- /deptrac.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - ./src/ 4 | - ./vendor/codeigniter4/framework/system/ 5 | exclude_files: 6 | - '#.*test.*#i' 7 | layers: 8 | - name: Model 9 | collectors: 10 | - type: bool 11 | must: 12 | - type: className 13 | regex: .*[A-Za-z]+Model$ 14 | must_not: 15 | - type: directory 16 | regex: vendor/.* 17 | - name: Vendor Model 18 | collectors: 19 | - type: bool 20 | must: 21 | - type: className 22 | regex: .*[A-Za-z]+Model$ 23 | - type: directory 24 | regex: vendor/.* 25 | - name: Controller 26 | collectors: 27 | - type: bool 28 | must: 29 | - type: className 30 | regex: .*\/Controllers\/.* 31 | must_not: 32 | - type: directory 33 | regex: vendor/.* 34 | - name: Vendor Controller 35 | collectors: 36 | - type: bool 37 | must: 38 | - type: className 39 | regex: .*\/Controllers\/.* 40 | - type: directory 41 | regex: vendor/.* 42 | - name: Config 43 | collectors: 44 | - type: bool 45 | must: 46 | - type: directory 47 | regex: src/Config/.* 48 | must_not: 49 | - type: className 50 | regex: .*Services 51 | - type: directory 52 | regex: vendor/.* 53 | - name: Vendor Config 54 | collectors: 55 | - type: bool 56 | must: 57 | - type: directory 58 | regex: vendor/.*/Config/.* 59 | must_not: 60 | - type: className 61 | regex: .*Services 62 | - name: Entity 63 | collectors: 64 | - type: bool 65 | must: 66 | - type: directory 67 | regex: src/Entities/.* 68 | must_not: 69 | - type: directory 70 | regex: vendor/.* 71 | - name: Vendor Entity 72 | collectors: 73 | - type: bool 74 | must: 75 | - type: directory 76 | regex: vendor/.*/Entities/.* 77 | - name: View 78 | collectors: 79 | - type: bool 80 | must: 81 | - type: directory 82 | regex: src/Views/.* 83 | must_not: 84 | - type: directory 85 | regex: vendor/.* 86 | - name: Vendor View 87 | collectors: 88 | - type: bool 89 | must: 90 | - type: directory 91 | regex: vendor/.*/Views/.* 92 | - name: Service 93 | collectors: 94 | - type: className 95 | regex: .*Services.* 96 | ruleset: 97 | Entity: 98 | - Config 99 | - Model 100 | - Service 101 | - Vendor Config 102 | - Vendor Entity 103 | - Vendor Model 104 | Config: 105 | - Service 106 | - Vendor Config 107 | Model: 108 | - Config 109 | - Entity 110 | - Service 111 | - Vendor Config 112 | - Vendor Entity 113 | - Vendor Model 114 | Service: 115 | - Config 116 | - Vendor Config 117 | 118 | # Ignore anything in the Vendor layers 119 | Vendor Model: 120 | - Config 121 | - Service 122 | - Vendor Config 123 | - Vendor Controller 124 | - Vendor Entity 125 | - Vendor Model 126 | - Vendor View 127 | Vendor Controller: 128 | - Service 129 | - Vendor Config 130 | - Vendor Controller 131 | - Vendor Entity 132 | - Vendor Model 133 | - Vendor View 134 | Vendor Config: 135 | - Config 136 | - Service 137 | - Vendor Config 138 | - Vendor Controller 139 | - Vendor Entity 140 | - Vendor Model 141 | - Vendor View 142 | Vendor Entity: 143 | - Service 144 | - Vendor Config 145 | - Vendor Controller 146 | - Vendor Entity 147 | - Vendor Model 148 | - Vendor View 149 | Vendor View: 150 | - Service 151 | - Vendor Config 152 | - Vendor Controller 153 | - Vendor Entity 154 | - Vendor Model 155 | - Vendor View 156 | skip_violations: 157 | -------------------------------------------------------------------------------- /src/Entities/Participant.php: -------------------------------------------------------------------------------- 1 | 'int', 19 | 'user_id' => 'int', 20 | ]; 21 | 22 | /** 23 | * Stored copy of the underlying User 24 | */ 25 | private ?UserEntity $user = null; 26 | 27 | //-------------------------------------------------------------------- 28 | // Getters 29 | //-------------------------------------------------------------------- 30 | 31 | /** 32 | * Returns a display name from the underlying user account 33 | */ 34 | public function getUsername(): string 35 | { 36 | if ($username = $this->getUser()->getUsername()) { 37 | return $username; 38 | } 39 | 40 | return isset($this->attributes['id']) ? 'Chatter' . $this->attributes['id'] : 'Chatter'; 41 | } 42 | 43 | /** 44 | * Returns a full name from the underlying user account 45 | */ 46 | public function getName(): string 47 | { 48 | return $this->getUser()->getName() ?? $this->getUsername(); 49 | } 50 | 51 | /** 52 | * Returns initials from the underlying user account 53 | */ 54 | public function getInitials(): string 55 | { 56 | $names = explode(' ', $this->getName()); 57 | $string = ''; 58 | 59 | foreach ($names as $name) { 60 | $string .= $name[0]; 61 | } 62 | 63 | return strtoupper($string); 64 | } 65 | 66 | //-------------------------------------------------------------------- 67 | // Activities 68 | //-------------------------------------------------------------------- 69 | 70 | /** 71 | * Updates this participant's last activity date 72 | * 73 | * @return $this 74 | */ 75 | public function active(): self 76 | { 77 | $this->attributes['updated_at'] = date('Y-m-d H:i:s'); 78 | 79 | model(ParticipantModel::class)->update($this->attributes['id'], [ 80 | 'updated_at' => $this->attributes['updated_at'], 81 | ]); 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * Creates a new message in the conversation and updates the activity timestamp 88 | * 89 | * @param string $content The content for the new message 90 | * 91 | * @return false|int|object|string ID of the new message 92 | */ 93 | public function say(string $content) 94 | { 95 | $data = [ 96 | 'conversation_id' => $this->attributes['conversation_id'], 97 | 'participant_id' => $this->attributes['id'], 98 | 'content' => $content, 99 | ]; 100 | 101 | if ($id = model(MessageModel::class)->insert($data)) { 102 | $this->active(); 103 | 104 | $data['id'] = $id; 105 | Events::trigger('chat', $data); 106 | } 107 | 108 | return $id; 109 | } 110 | 111 | //-------------------------------------------------------------------- 112 | // Utilities 113 | //-------------------------------------------------------------------- 114 | 115 | /** 116 | * Loads and returns the user account for this 117 | * participant using the UserProvider service 118 | */ 119 | private function getUser(): UserEntity 120 | { 121 | if ($this->user !== null) { 122 | return $this->user; 123 | } 124 | 125 | // Load the UserFactory from the provider 126 | $users = Services::users(); 127 | 128 | // If this is a Model then ignore soft delete status 129 | if (method_exists($users, 'withDeleted')) { 130 | $users->withDeleted(); 131 | } 132 | 133 | // Get the User 134 | if (! $this->user = $users->findById($this->attributes['user_id'])) { 135 | $error = 'Unable to locate User ID: ' . $this->attributes['user_id']; 136 | log_message('error', $error); 137 | 138 | throw new RuntimeException($error); 139 | } 140 | 141 | return $this->user; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | sets([SetList::DEAD_CODE, LevelSetList::UP_TO_PHP_74, PHPUnitSetList::PHPUNIT_SPECIFIC_METHOD, PHPUnitSetList::PHPUNIT_80]); 40 | $rectorConfig->parallel(); 41 | // The paths to refactor (can also be supplied with CLI arguments) 42 | $rectorConfig->paths([ 43 | __DIR__ . '/src/', 44 | __DIR__ . '/tests/', 45 | ]); 46 | 47 | // Include Composer's autoload - required for global execution, remove if running locally 48 | $rectorConfig->autoloadPaths([ 49 | __DIR__ . '/vendor/autoload.php', 50 | ]); 51 | 52 | // Do you need to include constants, class aliases, or a custom autoloader? 53 | $rectorConfig->bootstrapFiles([ 54 | realpath(getcwd()) . '/vendor/codeigniter4/framework/system/Test/bootstrap.php', 55 | ]); 56 | 57 | if (is_file(__DIR__ . '/phpstan.neon.dist')) { 58 | $rectorConfig->phpstanConfig(__DIR__ . '/phpstan.neon.dist'); 59 | } 60 | 61 | // Set the target version for refactoring 62 | $rectorConfig->phpVersion(PhpVersion::PHP_74); 63 | 64 | // Auto-import fully qualified class names 65 | $rectorConfig->importNames(); 66 | 67 | // Are there files or rules you need to skip? 68 | $rectorConfig->skip([ 69 | __DIR__ . '/src/Views', 70 | 71 | JsonThrowOnErrorRector::class, 72 | StringifyStrNeedlesRector::class, 73 | 74 | // Note: requires php 8 75 | RemoveUnusedPromotedPropertyRector::class, 76 | 77 | // Ignore tests that might make calls without a result 78 | RemoveEmptyMethodCallRector::class => [ 79 | __DIR__ . '/tests', 80 | ], 81 | 82 | // Ignore files that should not be namespaced 83 | NormalizeNamespaceByPSR4ComposerAutoloadRector::class => [ 84 | __DIR__ . '/src/Helpers', 85 | ], 86 | 87 | // May load view files directly when detecting classes 88 | StringClassNameToClassConstantRector::class, 89 | 90 | // May be uninitialized on purpose 91 | AddDefaultValueForUndefinedVariableRector::class, 92 | ]); 93 | $rectorConfig->rule(SimplifyUselessVariableRector::class); 94 | $rectorConfig->rule(RemoveAlwaysElseRector::class); 95 | $rectorConfig->rule(CountArrayToEmptyArrayComparisonRector::class); 96 | $rectorConfig->rule(ForToForeachRector::class); 97 | $rectorConfig->rule(ChangeNestedForeachIfsToEarlyContinueRector::class); 98 | $rectorConfig->rule(ChangeIfElseValueAssignToEarlyReturnRector::class); 99 | $rectorConfig->rule(SimplifyStrposLowerRector::class); 100 | $rectorConfig->rule(CombineIfRector::class); 101 | $rectorConfig->rule(SimplifyIfReturnBoolRector::class); 102 | $rectorConfig->rule(InlineIfToExplicitIfRector::class); 103 | $rectorConfig->rule(PreparedValueToEarlyReturnRector::class); 104 | $rectorConfig->rule(ShortenElseIfRector::class); 105 | $rectorConfig->rule(SimplifyIfElseToTernaryRector::class); 106 | $rectorConfig->rule(UnusedForeachValueToArrayKeysRector::class); 107 | $rectorConfig->rule(ChangeArrayPushToArrayAssignRector::class); 108 | $rectorConfig->rule(UnnecessaryTernaryExpressionRector::class); 109 | $rectorConfig->rule(AddPregQuoteDelimiterRector::class); 110 | $rectorConfig->rule(SimplifyRegexPatternRector::class); 111 | $rectorConfig->rule(FuncGetArgsToVariadicParamRector::class); 112 | $rectorConfig->rule(MakeInheritedMethodVisibilitySameAsParentRector::class); 113 | $rectorConfig->rule(SimplifyEmptyArrayCheckRector::class); 114 | $rectorConfig->rule(NormalizeNamespaceByPSR4ComposerAutoloadRector::class); 115 | $rectorConfig 116 | ->ruleWithConfiguration(TypedPropertyRector::class, [ 117 | // Set to false if you use in libraries, or it does create breaking changes. 118 | TypedPropertyRector::INLINE_PUBLIC => true, 119 | ]); 120 | }; 121 | --------------------------------------------------------------------------------