tl;dr;
Let’s add channels to our chat. Users will be able to jump between channels to discuss different topics.
First thing first: database changes
Channels are a new entity within the project. They will need a similar treatment to Messages in terms of database. They’ll need a migration, model, factory and some logic in the seeder to get some channels in the database for us to use during development.
Let’s go through all this stuff one by one.
Migrations
Channels will be stored in a new table, so let’s get that created with a new migration:
1php artisan make:migration create_channels_table
This is the code to add to the created file:
1<?php 2 3use Illuminate\Database\Migrations\Migration; 4use Illuminate\Database\Schema\Blueprint; 5use Illuminate\Support\Facades\Schema; 6 7return new class extends Migration 8{ 9 /**10 * Run the migrations.11 */12 public function up(): void13 {14 Schema::create('channels', function (Blueprint $table) {15 $table->id();16 $table->foreignId('user_id')->constrained()->cascadeOnDelete();17 $table->foreignId('team_id')->constrained()->cascadeOnDelete();18 $table->string('name');19 $table->timestamps();20 });21 }22 23 /**24 * Reverse the migrations.25 */26 public function down(): void27 {28 Schema::dropIfExists('channels');29 }30};
database/migrations/2024_04_11_124500_create_channels_table.php
A channel will belong to the user that created it and will also belong to the team the user was in when creating it.
The main piece of data we need now about a channel is its name. Later on we’ll add more stuff like the possibility to make it private, but for now we can keep it simple.
With channels in place, messages don’t have to be connected to teams anymore, but they must be connected to a channel. To achieve this we can change the migration created in the previous article:
1Schema::create('messages', function (Blueprint $table) {2 ...3 $table->foreignId('team_id')->constrained()->cascadeOnDelete(); 4 $table->foreignId('channel_id')->constrained()->cascadeOnDelete(); 5 ...6});
Note that we want channels migration to run before messages migration. To achieve this I renamed the channels migration file so the portion of file name with date is precedent to the one of messages migration file.
In my migration folder I can see the following list with files in this exact order:
…
2024_04_11_124354_create_team_invitations_table.php
2024_04_11_124500_create_channels_table.php
2024_04_11_124923_create_messages_table.php
Models
Now I can create the Channel model:
1php artisan make:model Channel
1class Channel extends Model2{3 use HasFactory;4 5 protected $guarded = [];6}
We can now add a relationship to Team model to connect to Channels:
1class Team extends JetstreamTeam2{3 ...4 5 public function channels(): HasMany6 {7 return $this->hasMany(Channel::class);8 }9}
app/Models/Team.php
This way we’ll be able to read all channels connected to the team a user is currently in.
Factories
The new Channel model now requires a factory so we can bulk generate channels in the seeder or in tests (which we’ll cover in a later article).
1php artisan make:factory ChannelFactory
1class ChannelFactory extends Factory 2{ 3 /** 4 * Define the model's default state. 5 * 6 * @return array<string, mixed> 7 */ 8 public function definition(): array 9 {10 return [11 'user_id' => User::factory(),12 'team_id' => function (array $attributes) {13 return User::find($attributes['user_id'])->currentTeam->id;14 },15 'name' => $this->faker->slug,16 ];17 }18}
database/factories/ChannelFactory.php
For each channel created through the factory we must be sure the team is connected to, is the current team for the user. This can be done by using a closure.
The change done to the Messages table require changing the factory too, as now messages must be connected to channels:
1class MessageFactory extends Factory 2{ 3 /** 4 * Define the model's default state. 5 * 6 * @return array<string, mixed> 7 */ 8 public function definition(): array 9 {10 return [11 'user_id' => User::factory(),12 'team_id' => function (array $attributes) { 13 return User::find($attributes['user_id'])->currentTeam->id; 14 }, 15 'channel_id' => Channel::factory(),16 'content' => $this->faker->sentence,17 ];18 }19}
Seeder
When working on seeders I like to generate data that seems as close as possible to the real thing. This makes it easier and more enjoyable to work with the app in the browser during development.
What I wanted was to have a list of channels on specific themes. So I asked ChatGPT to come up with two lists: one about board games, the other about legos.
For this reason the channels seeder looks like this:
1<?php 2 3namespace Database\Seeders; 4 5use App\Models\User; 6use Illuminate\Database\Console\Seeds\WithoutModelEvents; 7use Illuminate\Database\Seeder; 8 9class ChannelSeeder extends Seeder10{11 /**12 * Run the database seeds.13 */14 public function run(): void15 {16 $luca = User::find(1);17 $viola = User::find(2);18 19 $gamesChannelNames = collect([20 'dungeons-and-dragons',21 'pathfinder',22 'warhammer-fantasy-roleplay',23 'call-of-cthulhu',24 'shadowrun',25 'gurps',26 'vampire-the-masquerade',27 'star-wars-edge-of-the-empire',28 'the-world-of-darkness',29 'runequest',30 'cyberpunk-2020',31 'deadlands',32 'traveller',33 'legend-of-the-five-rings',34 'mouse-guard',35 'ars-magica',36 'fiasco',37 'fate-core',38 'numenera',39 'mutants-and-masterminds',40 ]);41 42 $legoChannelNames = collect([43 'lego-sets',44 'lego-builds',45 'lego-minifigures',46 'lego-mocs',47 'lego-technic',48 'lego-city',49 'lego-star-wars',50 'lego-harry-potter',51 'lego-architecture',52 'lego-ideas',53 'lego-creator',54 'lego-ninjago',55 'lego-friends',56 'lego-marvel',57 'lego-duplo',58 'lego-education',59 'lego-video-games',60 'lego-animation',61 'lego-community',62 'lego-news'63 ]);64 65 $luca->ownedTeams->each(function ($team) use ($luca, $gamesChannelNames) {66 foreach ($gamesChannelNames as $channelName) {67 $team->channels()->create([68 'user_id' => $luca->id,69 'name' => $channelName,70 ]);71 }72 });73 74 $viola->ownedTeams->each(function ($team) use ($viola, $legoChannelNames) {75 foreach ($legoChannelNames as $channelName) {76 $team->channels()->create([77 'user_id' => $viola->id,78 'name' => $channelName,79 ]);80 }81 });82 }83}
database/seeders/ChannelSeeder.php
For the same reason I changed the messages seeder. I asked again ChatGPT for an hipotetical conversation between two persons about Dungeons & Dragons and used that as the list of messages for one of the channels created by the channels seeder.
That will be the main channel I’ll use to interact with the app in the browser. For all other channels I’ll just use lorem ipsum messages generated by faker.
The resulting code is this:
1class MessageSeeder extends Seeder 2{ 3 /** 4 * Run the database seeds. 5 */ 6 public function run(): void 7 { 8 Message::unsetEventDispatcher(); 9 10 Channel::all()->each(function (Channel $channel) { 11 if($channel->name == 'dungeons-and-dragons') { 12 $dndConversation = [ 13 ['user_id' => 1, 'content' => "Hey, have you read the latest adventure module?", 'datetime' => '2024-04-13 09:05:00'], 14 ['user_id' => 2, 'content' => "Yeah, I just finished it. It looks amazing!", 'datetime' => '2024-04-13 09:10:00'], 15 ['user_id' => 1, 'content' => "I'm thinking of creating a new character for our next campaign.", 'datetime' => '2024-04-13 09:15:00'], 16 ['user_id' => 2, 'content' => "That sounds exciting! What kind of character are you thinking of?", 'datetime' => '2024-04-13 09:20:00'], 17 ['user_id' => 1, 'content' => "Do you prefer playing as a player or as a dungeon master?", 'datetime' => '2024-04-13 09:25:00'], 18 ['user_id' => 2, 'content' => "I enjoy both, but being a player lets me immerse myself more in the story.", 'datetime' => '2024-04-13 09:30:00'], 19 ['user_id' => 1, 'content' => "That makes sense. I feel like being a DM gives me more creative control.", 'datetime' => '2024-04-13 09:35:00'], 20 ['user_id' => 2, 'content' => "Yeah, being a DM is like being the conductor of an epic story.", 'datetime' => '2024-04-13 09:40:00'], 21 ['user_id' => 1, 'content' => "Exactly! Plus, you get to surprise your players with unexpected twists.", 'datetime' => '2024-04-13 09:45:00'], 22 ['user_id' => 2, 'content' => "I love the feeling of discovery as a player. It's like uncovering a hidden treasure.", 'datetime' => '2024-04-13 09:50:00'], 23 ['user_id' => 1, 'content' => "That's a great way to put it. It's all about the journey.", 'datetime' => '2024-04-13 09:55:00'], 24 ['user_id' => 2, 'content' => "Absolutely. So, what's your favorite part about playing D&D?", 'datetime' => '2024-04-13 10:00:00'], 25 ['user_id' => 1, 'content' => "I think my favorite part is the camaraderie among the players. We're all in it together.", 'datetime' => '2024-04-13 10:05:00'], 26 ['user_id' => 2, 'content' => "I agree. It's like forging bonds through shared adventures.", 'datetime' => '2024-04-13 10:10:00'], 27 ['user_id' => 1, 'content' => "Exactly! Plus, the memories we create together last a lifetime.", 'datetime' => '2024-04-13 10:15:00'], 28 ['user_id' => 2, 'content' => "For sure. I still remember our first campaign like it was yesterday.", 'datetime' => '2024-04-13 10:20:00'], 29 ['user_id' => 1, 'content' => "Me too. It was a wild ride from start to finish.", 'datetime' => '2024-04-13 10:25:00'], 30 ['user_id' => 2, 'content' => "We should organize a reunion game sometime. It would be a blast!", 'datetime' => '2024-04-13 10:30:00'], 31 ['user_id' => 1, 'content' => "That's a fantastic idea! I'll reach out to the old gang and see who's available.", 'datetime' => '2024-04-13 10:35:00'], 32 ['user_id' => 2, 'content' => "Awesome! I can't wait to roll some dice with everyone again.", 'datetime' => '2024-04-13 10:40:00'], 33 ['user_id' => 1, 'content' => "It's settled then. I'll start planning the adventure.", 'datetime' => '2024-04-13 10:45:00'], 34 ['user_id' => 2, 'content' => "Count me in! Let's make it an unforgettable reunion.", 'datetime' => '2024-04-13 10:50:00'], 35 ['user_id' => 1, 'content' => "I'll start brainstorming some plot hooks and encounters.", 'datetime' => '2024-04-13 10:55:00'], 36 ['user_id' => 1, 'content' => "Maybe we could revisit some classic villains from our old campaigns.", 'datetime' => '2024-04-13 11:00:00'], 37 ['user_id' => 2, 'content' => "That's a great idea! Nostalgia mixed with new adventures.", 'datetime' => '2024-04-13 11:05:00'], 38 ['user_id' => 2, 'content' => "I'm sure everyone will love the trip down memory lane.", 'datetime' => '2024-04-13 11:10:00'], 39 ['user_id' => 2, 'content' => "Let me know if you need any help with planning or organizing.", 'datetime' => '2024-04-13 11:15:00'], 40 ['user_id' => 1, 'content' => "Thanks, I appreciate it! Your input will make the reunion even better.", 'datetime' => '2024-04-13 11:20:00'], 41 ['user_id' => 1, 'content' => "I'll create a group chat and start reaching out to everyone.", 'datetime' => '2024-04-13 11:25:00'], 42 ['user_id' => 1, 'content' => "It'll be great to catch up with everyone and relive our epic adventures.", 'datetime' => '2024-04-13 11:30:00'], 43 ['user_id' => 2, 'content' => "Absolutely! I can't wait to hear what everyone has been up to.", 'datetime' => '2024-04-13 11:35:00'], 44 ['user_id' => 2, 'content' => "I'm sure there are plenty of new tales to share since our last adventure.", 'datetime' => '2024-04-13 11:40:00'], 45 ['user_id' => 2, 'content' => "Plus, it'll be fun to see how everyone's characters have evolved.", 'datetime' => '2024-04-13 11:45:00'], 46 ['user_id' => 1, 'content' => "Definitely! It'll be like a mini-reunion of our beloved party.", 'datetime' => '2024-04-13 11:50:00'], 47 ['user_id' => 1, 'content' => "I'll send out the invites today and finalize the details.", 'datetime' => '2024-04-13 11:55:00'], 48 ['user_id' => 1, 'content' => "Get ready for an epic adventure filled with laughter and nostalgia!", 'datetime' => '2024-04-13 12:00:00'], 49 ['user_id' => 2, 'content' => "Hey, have you ever tried playing as a barbarian?", 'datetime' => '2024-04-13 12:05:00'], 50 ['user_id' => 1, 'content' => "Yeah, I played a half-orc barbarian in a one-shot adventure once.", 'datetime' => '2024-04-13 12:10:00'], 51 ['user_id' => 1, 'content' => "It was exhilarating charging into battle and smashing enemies with brute force.", 'datetime' => '2024-04-13 12:15:00'], 52 ['user_id' => 2, 'content' => "That sounds epic! I might have to roll up a barbarian for our next game.", 'datetime' => '2024-04-13 12:20:00'], 53 ['user_id' => 2, 'content' => "Do you have any tips for playing a barbarian effectively?", 'datetime' => '2024-04-13 12:25:00'], 54 ['user_id' => 1, 'content' => "Definitely! The key is to embrace your character's primal instincts and rage.", 'datetime' => '2024-04-13 12:30:00'], 55 ['user_id' => 1, 'content' => "Use your rage to fuel your attacks and shrug off damage like it's nothing.", 'datetime' => '2024-04-13 12:35:00'], 56 ['user_id' => 1, 'content' => "Just be careful not to go berserk and attack your allies by mistake.", 'datetime' => '2024-04-13 12:40:00'], 57 ['user_id' => 2, 'content' => "Got it. I'll channel my inner rage and unleash havoc on our enemies.", 'datetime' => '2024-04-13 12:45:00'], 58 ['user_id' => 2, 'content' => "I can't wait to see the fear in their eyes when they face my barbarian!", 'datetime' => '2024-04-13 12:50:00'], 59 ['user_id' => 1, 'content' => "That's the spirit! Your barbarian is going to be a force to be reckoned with.", 'datetime' => '2024-04-13 12:55:00'], 60 ['user_id' => 1, 'content' => "Just remember to balance brute strength with cunning strategy.", 'datetime' => '2024-04-13 13:00:00'], 61 ['user_id' => 2, 'content' => "I'll keep that in mind. It's all about finding the right moment to strike.", 'datetime' => '2024-04-13 13:05:00'], 62 ['user_id' => 2, 'content' => "Speaking of strategy, do you have any favorite tactics for combat?", 'datetime' => '2024-04-13 13:10:00'], 63 ['user_id' => 1, 'content' => "I'm a big fan of flanking maneuvers and ambushes.", 'datetime' => '2024-04-13 13:15:00'], 64 ['user_id' => 1, 'content' => "Catch your enemies off guard and exploit their weaknesses.", 'datetime' => '2024-04-13 13:20:00'], 65 ['user_id' => 2, 'content' => "Sounds sneaky! I'll have to incorporate those tactics into my gameplay.", 'datetime' => '2024-04-13 13:25:00'], 66 ['user_id' => 2, 'content' => "I can already imagine the look on our DM's face when we pull off a daring ambush.", 'datetime' => '2024-04-13 13:30:00'], 67 ['user_id' => 1, 'content' => "It'll be legendary! Our enemies won't know what hit them.", 'datetime' => '2024-04-13 13:35:00'], 68 ['user_id' => 1, 'content' => "Just make sure to coordinate with the rest of the party to maximize our effectiveness.", 'datetime' => '2024-04-13 13:40:00'], 69 ['user_id' => 2, 'content' => "Got it. Teamwork makes the dream work, right?", 'datetime' => '2024-04-13 13:45:00'], 70 ['user_id' => 2, 'content' => "I'll make sure we're all on the same page when it comes to battle strategies.", 'datetime' => '2024-04-13 13:50:00'], 71 ['user_id' => 1, 'content' => "That's the spirit! With our combined skills and teamwork, we'll be unstoppable.", 'datetime' => '2024-04-13 13:55:00'], 72 ['user_id' => 1, 'content' => "I can't wait to see what kind of epic adventures await us in our next campaign.", 'datetime' => '2024-04-13 14:00:00'], 73 ['user_id' => 2, 'content' => "Agreed! It's going to be an adventure for the ages.", 'datetime' => '2024-04-13 14:05:00'], 74 ['user_id' => 2, 'content' => "I'll make sure to bring my A-game and give it my all.", 'datetime' => '2024-04-13 14:10:00'], 75 ['user_id' => 1, 'content' => "That's what I like to hear! Let's make this campaign one to remember.", 'datetime' => '2024-04-13 14:15:00'], 76 ['user_id' => 1, 'content' => "I'll start preparing some epic quests and challenges for our party.", 'datetime' => '2024-04-13 14:20:00'], 77 ['user_id' => 1, 'content' => "Get ready for an adventure of a lifetime!", 'datetime' => '2024-04-13 14:25:00'], 78 ]; 79 80 foreach($dndConversation as $message) { 81 Message::factory()->create([ 82 'user_id' => $message['user_id'], 83 'channel_id' => $channel->id, 84 'content' => $message['content'], 85 'created_at' => $message['datetime'], 86 'updated_at' => $message['datetime'], 87 ]); 88 } 89 } else { 90 Message::factory(10) 91 ->recycle(User::all()) 92 ->recycle($channel) 93 ->state(new Sequence( 94 fn (Sequence $sequence) => [ 95 'created_at' => Carbon::create(2024)->addMinutes($sequence->index * 5), 96 'updated_at' => Carbon::create(2024)->addMinutes($sequence->index * 5) 97 ], 98 )) 99 ->create();100 }101 });102 103 }104}
database/seeders/MessageSeeder.php
The first line of the seeder is Message::unsetEventDispatcher();
. This is to speed up a bit the seeder as it must create quite a few records.
In the previous article we set up an observer that raised an event for each new message so all users looking at the chat would get the message without refreshing the page.
That’s something we don’t need when we are seeding the database, so we can disable it with Message::unsetEventDispatcher();
. This result in a small speed up of the seeding process.
And this was the last thing needed on database level. We can run:
1php artisan migrate:fresh --seed
and jump on the other side of the application to make channels visible in the browser.
Backend changes
Routing and navigation
Up until now the chat was available in the dashboard page. It’s time to move it to its own route. To do this we can change the web.php file:
1Route::middleware([2 ...3])->group(function () {4 ---5 6 Route::get('/chat/{id?}', Chat::class)->name('chat');7});
routes/web.php
The new route is meant to connect the livewire Chat component to a url. The url has an optional id
parameter. This is because the user will be able to go to a specific channel or go to a default one (which at first will just be the first channel in the list).
With that new route available we can now add a new link in the main navigation:
1<!-- Navigation Links --> 2<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"> 3 <x-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')"> 4 {{ __('Dashboard') }} 5 </x-nav-link> 6 7 <x-nav-link href="{{ route('chat') }}" :active="request()->routeIs('chat')"> 8 {{ __('Chat') }} 9 </x-nav-link>10</div>
resources/views/navigation-menu.blade.php
And remove the chat component from the dashboard:
1<div class="py-12">2 <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">3 <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">4 <livewire:chat /> // [tl! remove]5 </div>6 </div>7</div>
resources/views/dashboard.blade.php
Livewire Chat component
The new route takes care of passing a channel id to the Chat component. It’s time to use it to retrieve that channel from the database:
1class Chat extends Component 2{ 3 ... 4 5 public Channel $channel; 6 7 public function mount(?int $id = null): void 8 { 9 if ($id) {10 $this->channel = Channel::where('team_id', Auth::user()->currentTeam->id)11 ->findOrFail($id);12 13 return;14 }15 16 $this->channel = Auth::user()->currentTeam->channels->first();17 }18 19 ...
app/Livewire/Chat.php
We define a public property $channel, to make the selected channel available on the component frontend.
Then we can initialize it in the mount method that runs when the component is first initialized.
If a channel id is passed to the component, we can do a query to retrieved it. Making sure we scope the query to the current user’s team.
Otherwise we just pick the first channel available. Later on we can make this a bit smarter. For example store the last channel visited by the user and propose that one as default.
With a current channel defined, we can now change the render method to load messages only for that specific channel:
1public function render() 2{ 3 $messages = Message::query() 4 ->where('channel_id', $this->channel->id) 5 ->with('user') 6 ->orderBy('created_at') 7 ->get(); 8 9 return view('livewire.chat')10 ->with([11 'messages' => $messages,12 ]);13}
app/Livewire/Chat.php
In the component UI we want to display a list of all channels so users can jump between them. We can do this by changing the render method:
1public function render() 2{ 3 $channels = Channel::query() 4 ->where('team_id', auth()->user()->currentTeam->id) 5 ->get(); 6 7 $messages = Message::query() 8 ->where('channel_id', $this->channel->id) 9 ->with('user')10 ->orderBy('created_at')11 ->get();12 13 return view('livewire.chat')14 ->with([15 'channels' => $channels,16 'messages' => $messages,17 ]);18}
app/Livewire/Chat.php
New messages created by the component must be connected to the current channel, so we need to change the send() method:
1public function send() 2{ 3 Message::create([ 4 'user_id' => auth()->id(), 5 'team_id' => auth()->user()->currentTeam->id, 6 'channel_id' => $this->channel->id, 7 'content' => $this->message, 8 ]); 9 10 $this->reset(['message']);11}
app/Livewire/Chat.php
When a new message is created we must reset only the $message
field. Using reset without parameters will reset all fields, including $channel
which should not be reset as otherwise the component won’t be able to work properly.
Before moving on to the Chat component frontend we must set one last thing: the layout used by this component. Since now Chat is its own page, we must wrap it in a full html page.
The layout for a component can be defined by the #[Layout] attribute on the render method:
1#[Layout('layouts.app')]2public function render()3{4 ...5 return view('livewire.chat')6 ->title($this->channel->name)7 ...
app/Livewire/Chat.php
layouts.app
is the main blade layout provided by Jetstream.
We can also set the meta title for that page by using the title method when returning the view. Which can then be used like this to have the channel name as title of the page:
1...2<title>{{ (isset($title) ? $title . ' - ' : '') . config('app.name', 'Laravel') }}</title>
resources/views/layouts/app.blade.php
Frontend changes
This is the result we want to archive on the frontend:
The chat component must take up all available space in the page. The page should not scroll vertically.
The text area to type a message will always have to be visible at the bottom of the page.
List of channels and messages should scroll independently.
First of all we can change to main layout:
1<div class="min-h-screen bg-gray-100 has-[#chat]:flex has-[#chat]:flex-col">2 ...3 4 <!-- Page Content -->5 <main class="has-[#chat]:flex-1 relative">6 {{ $slot }}7 </main>8</div>
If the page contains the chat component, the main content will use flex (has-[#chat]:flex
).
The div including the chat component will use flex-1
to take up all the available vertical space.
Next we can take care of chat.blade.php where most changes will be. Let’s edit the outermost div, assign it an id of chat and set it as position absolute and take up all available space on the page:
1<div id="chat" class="absolute inset-0 m-4 overflow-hidden shadow-xl rounded-lg bg-white">2 ...3</div>
Inside of it we can now have another div with flex and within in two more divs which will be two columns containing list of channels and list of messages:
1<div id="chat" class="absolute inset-0 m-4 overflow-hidden shadow-xl rounded-lg bg-white"> 2 <div class="flex divide-x h-full"> 3 {{-- list of channels --}} 4 <div class="w-1/5 overflow-y-scroll"> 5 ... 6 </div> 7 8 {{-- list of messages and message editor --}} 9 <div class="w-4/5 flex flex-col">10 ...11 </div>12 </div>13</div>
resources/views/livewire/chat.blade.php
Let’s focus on the list of channels first. We can isolate that in a dedicated blade component. We can use this artisan command to create it:
1php artisan make:component chat.channels --view
The new file will be resources/views/components/chat/channels.blade.php. The —view option makes sure that only a blade file will be created. No backend php file associated to it.
We can now loop over the list of channels and display them all as links:
1<div class="px-2 py-4"> 2 <div class="font-semibold mb-4">Channels</div> 3 <div class="space-y-px"> 4 @foreach($channels as $channel) 5 @if($channel->id !== $currentChannel->id) 6 <a class="block hover:bg-indigo-100 py-1 px-2 rounded-md text-nowrap overflow-ellipsis w-full overflow-hidden" 7 href="{{ route('chat', ['id' => $channel->id]) }}" 8 title="{{ $channel->name }}" 9 >10 <span class="inline-block mr-2">#</span> <span>{{ $channel->name }}</span>11 </a>12 @else13 <div class="block py-1 px-2 rounded-md font-semibold bg-indigo-400 text-white text-nowrap overflow-ellipsis w-full overflow-hidden"14 title="{{ $channel->name }}"15 >16 <span class="inline-block mr-2">#</span> <span>{{ $channel->name }}</span>17 </div>18 @endif19 @endforeach20 </div>21</div>
resources/views/components/chat/channels.blade.php
The list of channels can be placed in chat.blade.php:
1<div id="chat" class="absolute inset-0 m-4 overflow-hidden shadow-xl rounded-lg bg-white"> 2 <div class="flex divide-x h-full"> 3 {{-- list of channels --}} 4 <div class="w-1/5 overflow-y-scroll"> 5 <x-chat.channels :channels="$channels" :currentChannel="$channel"/> 6 </div> 7 8 {{-- list of messages and message editor --}} 9 <div class="w-4/5 flex flex-col">10 ...11 </div>12 </div>13</div>
resources/views/livewire/chat.blade.php
Messages and message editor blocks can be moved to their own components:
1php artisan make:component chat.message-editor --view2php artisan make:component chat.messages --view
Message editor
1<div class="flex items-start space-x-4"> 2 <div class="min-w-0 flex-1"> 3 <form wire:submit="send" class="relative"> 4 <div 5 class="overflow-hidden rounded-lg shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-indigo-600"> 6 <label for="comment" class="sr-only">Send a message</label> 7 <textarea wire:model="message" @keydown.enter="$wire.send" rows="3" name="message" id="message" 8 class="block w-full resize-none border-0 bg-transparent py-1.5 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" 9 placeholder="Send a message..."></textarea>10 11 <div class="py-2" aria-hidden="true">12 <div class="py-px">13 <div class="h-9"></div>14 </div>15 </div>16 </div>17 18 <div class="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">19 <div class="flex-shrink-0">20 <button type="submit"21 class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">22 Post23 </button>24 </div>25 </div>26 </form>27 </div>28</div>
resources/views/components/chat/message-editor.blade.php
Right now pressing enter while in the message editor will only result in the cursor going to the next line. One improvement that can be done is to send the message on enter instead.
This can be easily achieved by adding @keydown.enter="$wire.send"
to the text area.
Messages
1<div x-ref="scroller" class="divide-y h-full overflow-y-scroll" x-init="() => { $refs.scroller.scroll(0, $refs.scroller.scrollHeight); }"> 2 @foreach($messages as $message) 3 <div class="flex space-x-4 px-4 py-2 sm:[overflow-anchor:none]"> 4 <div 5 class="flex-shrink-0 w-10 h-10 flex items-center justify-center text-lg font-semibold rounded-full bg-gray-200"> 6 {{ str($message->user->name)->upper()->limit(2, null) }} 7 </div> 8 <div> 9 <div class="flex space-x-4 items-center">10 <div class="font-semibold">{{ $message->user->name }}</div>11 <div class="text-sm">{{ $message->created_at }}</div>12 </div>13 <div>{{ $message->content }}</div>14 </div>15 </div>16 @endforeach17 <div class="h-px sm:[overflow-anchor:auto]"></div>18</div>
resources/views/components/chat/messages.blade.php
Messages can now scroll and by default the div containing them is always scrolled all the way to the bottom so the last message sent is visible right away.
This is achieved with a bit of CSS and Javascript.
The CSS part requires adding sm:[overflow-anchor:none]
to all messages, then add an empty div after all messages with class sm:[overflow-anchor:auto]
. This will ensure that as soon as the user starts to scroll, the div will jump at the bottom.
To avoid waiting for user interaction we can scroll to the bottom with Alpine x-init="() => { $refs.scroller.scroll(0, $refs.scroller.scrollHeight); }"
With this scroll will always be at the bottom by default both on page load and when a new message is sent or received.
For more info about this technique have a read here https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/
And this is how chat.blade.php will look in the end:
1<div id="chat" class="absolute inset-0 m-4 overflow-hidden shadow-xl rounded-lg bg-white"> 2 <div class="flex divide-x h-full"> 3 {{-- list of channels --}} 4 <div class="w-1/5 overflow-y-scroll"> 5 <x-chat.channels :channels="$channels" :currentChannel="$channel"/> 6 </div> 7 {{-- list of messages and message editor --}} 8 <div class="w-4/5 flex flex-col"> 9 <div class="flex-1 overflow-hidden">10 <x-chat.messages :messages="$messages"/>11 </div>12 <div class="p-4 pt-0">13 <x-chat.message-editor/>14 </div>15 </div>16 </div>17</div>
resources/views/livewire/chat.blade.php
Our chat is already looking very good! Next up allowing users to join channels.