├── 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 | = $message->getContent('markdown') ?>
20 | = substr($message->participant->username, 0, 2) ?>
34 |
35 |
36 |
37 |
38 |
39 | = substr($message->participant->username, 0, 2) ?>
53 | = $message->getContent('markdown') ?>
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/src/Views/conversation.php:
--------------------------------------------------------------------------------
1 |
2 |
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 |
= $day ?>
14 |
15 |
16 |
17 |
18 | = view('Tatter\Chat\Views\message', ['message' => $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 = "= site_url('chatapi/messages') ?>";
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/actions/workflows/phpunit.yml)
6 | [](https://github.com/tattersoftware/codeigniter4-chat/actions/workflows/phpstan.yml)
7 | [](https://github.com/tattersoftware/codeigniter4-chat/actions/workflows/deptrac.yml)
8 | [](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: `= chat('my-first-chat') ?>`
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 |
65 | = chat('product-7', 'Live Chat') ?>
66 |
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 |
--------------------------------------------------------------------------------