Skip to content
Luca Ubiali Web Developer

Channels and some UI adjustments

April 19th, 2024
Woki-Toki

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(): void
13 {
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(): void
27 {
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 Model
2{
3 use HasFactory;
4 
5 protected $guarded = [];
6}

We can now add a relationship to Team model to connect to Channels:

1class Team extends JetstreamTeam
2{
3 ...
4 
5 public function channels(): HasMany
6 {
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 Seeder
10{
11 /**
12 * Run the database seeds.
13 */
14 public function run(): void
15 {
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 @else
13 <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 @endif
19 @endforeach
20 </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 --view
2php 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 Post
23 </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 @endforeach
17 <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.