The goal of this lesson will be to set up a mechanism where by we can highlight new content to returning visitors of the website. So for example, consider a user visits the site and reads all of the threads on the front page and then leaves for the day. Later, a different visitor visits the site and adds a new reply to say three different threads. When the first user returns, those three threads with new replies should be highlighted in some way. In our case, maybe we can add a bold font to threads that have new content to highlight to the user. Let’s see how we might implement this now.
Driving New Content Via Cache
All models in Laravel make use of an updated_at field in the database. This means that we will know what the most recent update has been on a particular thread. What that means is that if we record a timestamp of when a user visits a particular thread, then we can compare that timestamp against the updated_at value to see which is newer. If the user visited timestamp is newer than the updated_at timestamp, then no highlighting of content is needed. On the other hand, if the updated_at value is newer than the cache timestamp of the user’s most recent visit, that means that there is new content that should be highlighted for the user.
Record User Visits
If we are following the logic of comparing a time value from cache versus what is in the database, then we need a way to record when the user has visited a given page. Perhaps we can add a couple of methods. Highlighted below are the read() and visitedThreadCacheKey() methods in the User model. The visitedThreadCacheKey() is where the meat of this feature happens. In it, a unique string gets defined as the key for which to use when storing a record of a visit in the cache. Working in concert with the read() method, we store a new key in the cache where it is equal to the current time according to Carbon::now().
<?php
namespace App;
use CarbonCarbon;
use IlluminateNotificationsNotifiable;
use IlluminateFoundationAuthUser as Authenticatable;
class User extends Authenticatable
{
use Notifiable;
protected $fillable = [
'name', 'email', 'password',
];
protected $hidden = [
'password', 'remember_token', 'email'
];
public function getRouteKeyName()
{
return 'name';
}
public function threads()
{
return $this->hasMany(Thread::class)->latest();
}
public function activity()
{
return $this->hasMany(Activity::class);
}
public function read($thread)
{
cache()->forever(
$this->visitedThreadCacheKey($thread),
Carbon::now()
);
}
public function visitedThreadCacheKey($thread)
{
return sprintf("users.%s.visits.%s", $this->id, $thread->id);
}
}
Checking For Updates on Thread Model
The methods that are doing the heavy lifting for this feature are already now defined in the User model. Now, we can add a method to the Thread model which checks for new updates for a user. Note the hasUpdatesFor() method highlighted below in the Thread model.
<?php
namespace App;
use AppEventsThreadReceivedNewReply;
use IlluminateDatabaseEloquentModel;
use AppUser;
class Thread extends Model
{
use RecordsActivity;
protected $guarded = [];
protected $with = ['creator', 'channel'];
protected $appends = ['isSubscribedTo'];
protected static function boot()
{
parent::boot();
static::addGlobalScope('replyCount', function ($builder) {
$builder->withCount('replies');
});
static::deleting(function ($thread) {
$thread->replies->each->delete();
});
}
public function path()
{
return '/threads/' . $this->channel->slug . '/' . $this->id;
}
public function replies()
{
return $this->hasMany(Reply::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'user_id');
}
public function channel()
{
return $this->belongsTo(Channel::class);
}
public function addReply($reply)
{
$reply = $this->replies()->create($reply);
event(new ThreadReceivedNewReply($reply));
return $reply;
}
public function scopeFilter($query, $filters)
{
return $filters->apply($query);
}
public function subscribe($userId = null)
{
$this->subscriptions()->create([
'user_id' => $userId ?: auth()->id()
]);
return $this;
}
public function unsubscribe($userId = null)
{
$this->subscriptions()
->where('user_id', $userId ?: auth()->id())
->delete();
}
public function subscriptions()
{
return $this->hasMany(ThreadSubscription::class);
}
public function getIsSubscribedToAttribute()
{
return $this->subscriptions()
->where('user_id', auth()->id())
->exists();
}
public function hasUpdatesFor(User $user)
{
$key = $user->visitedThreadCacheKey($this);
return $this->updated_at > cache($key);
}
}
What the hasUpdatesFor() method does for us is to check in the cache for the timestamp of when the user last visited the thread in question. Then we are making a comparison. We are checking to see if the updated_at timestamp is greater than the timestamp value found in the cache. If it is greater than, then something has happened like a new reply to update this thread. That means the user needs to see the new highlighted content so the function returns true.
Where will the actual recording of the visit take place? It makes sense that in the ThreadsController, the show() method is what is used to display a thread to a user. Therefore, if a logged in user visits a thread, this is the method which displays it to them. Highlighted below is a snippet that does a quick check to see if the user is logged in. If that is true, then we call the read() method which we defined on the User model just above. When this read() method triggers, it leans on the visitedThreadCacheKey() method to store a key in the cache using Carbon::now() as the timestamp. Great!
<?php
namespace AppHttpControllers;
use AppFiltersThreadFilters;
use AppThread;
use AppChannel;
use IlluminateHttpRequest;
class ThreadsController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show']);
}
public function index(Channel $channel, ThreadFilters $filters)
{
$threads = $this->getThreads($channel, $filters);
if (request()->wantsJson()) {
return $threads;
}
return view('threads.index', compact('threads'));
}
public function create()
{
return view('threads.create');
}
public function store(Request $request)
{
$this->validate($request, [
'title' => 'required',
'body' => 'required',
'channel_id' => 'required|exists:channels,id'
]);
$thread = Thread::create([
'user_id' => auth()->id(),
'channel_id' => request('channel_id'),
'title' => request('title'),
'body' => request('body')
]);
return redirect($thread->path())
->with('flash', 'Your thread was published!');
}
public function show($channel, Thread $thread)
{
if (auth()->check()) {
auth()->user()->read($thread);
}
return view('threads.show', [
'thread' => $thread
]);
}
public function edit(Thread $thread)
{
//
}
public function update(Request $request, Thread $thread)
{
//
}
public function destroy($channel, Thread $thread)
{
$this->authorize('update', $thread);
$thread->delete();
if (request()->wantsJson()) {
return response([], 204);
}
return redirect('/threads');
}
protected function getThreads(Channel $channel, ThreadFilters $filters)
{
$threads = Thread::latest()->filter($filters);
if ($channel->exists) {
$threads->where('channel_id', $channel->id);
}
$threads = $threads->get();
return $threads;
}
}
Adding Logic In Blade For Highlighting Content
Everything is in place now for this to work. All we need to do is update the threads/index.blade.php view file to highlight the new content. How will we do that? Well, this is where we can make use of that hasUpdatesFor() method we defined on the Thread model. The highlighted code below is checking to see if the thread has new content for the authenticated user that is currently signed in to the website. If that returns true, then the thread title is wrapped in <strong> tags to give a visual indication that there is new content ready for the user to catch up on. If it does not return true, then the title of the thread is displayed on the page with normal font.
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
@forelse($threads as $thread)
<div class="panel panel-default">
<div class="panel-heading">
<div class="level">
<h4 class="flex">
<a href="{{ $thread->path() }}">
@if (auth()->check() && $thread->hasUpdatesFor(auth()->user()))
<strong>
{{ $thread->title }}
</strong>
@else
{{ $thread->title }}
@endif
</a>
</h4>
<a href="{{$thread->path()}}">
<strong>{{$thread->replies_count}} {{ str_plural('reply', $thread->replies_count) }}</strong>
</a>
</div>
</div>
<div class="panel-body">
<div class="body">
{{ $thread->body }}
</div>
</div>
</div>
@empty
<p>There are no results yet</p>
@endforelse
</div>
</div>
</div>
@endsection
Seeing New Content Highlighted In Action
We are logged in as user Nikola Tesla and have visited all of the threads on the main page of the website. Therefore, we can see that no threads are highlighted below.
Along comes Tom and he decides to add a reply to the “Holy Guacamole!” thread.
Well, at this point, the logic we have now built in the site should be able to highlight the new content. In other words, the “Holy Guacamole!” thread title should now appear as bold when Nikola Tesla goes back to the website as a return visitor. It looks like this is working!
Note: If you have trouble getting this working, add a call to $this->touch(); in the addReply() method of the Thread model. This will ensure that the updated_at column is updating correctly when adding a new reply.
Build First, Test Later
This tutorial had us building first and testing later. No worries, we can add a test for this new content feature right here. The test is highlighted below.
<?php
namespace TestsUnit;
use IlluminateFoundationTestingDatabaseMigrations;
use TestsTestCase;
use IlluminateFoundationTestingRefreshDatabase;
class ThreadTest extends TestCase
{
use DatabaseMigrations;
protected $thread;
public function setUp()
{
parent::setUp();
$this->thread = $thread = factory('AppThread')->create();
}
public function test_a_thread_can_make_a_string_path()
{
$thread = create('AppThread');
$this->assertEquals('/threads/' . $thread->channel->slug . '/' . $thread->id, $thread->path());
}
public function test_a_thread_has_replies()
{
$this->assertInstanceOf('IlluminateDatabaseEloquentCollection', $this->thread->replies);
}
public function test_a_thread_has_a_creator()
{
$this->assertInstanceOf('AppUser', $this->thread->creator);
}
public function test_a_thread_can_add_a_reply()
{
$this->thread->addReply([
'body' => 'Chipoltle',
'user_id' => 1
]);
$this->assertCount(1, $this->thread->replies);
}
public function test_a_thread_belongs_to_a_channel()
{
$thread = create('AppThread');
$this->assertInstanceOf('AppChannel', $thread->channel);
}
public function test_a_thread_can_be_subscribed_to()
{
$thread = create('AppThread');
$thread->subscribe($userId = 1);
$this->assertEquals(
1,
$thread->subscriptions()->where('user_id', $userId)->count()
);
}
public function test_a_thread_can_be_unsubscribed_from()
{
$thread = create('AppThread');
$thread->subscribe($userId = 1);
$thread->unsubscribe($userId);
$this->assertCount(0, $thread->subscriptions);
}
public function test_a_thread_can_check_if_the_authenticated_user_has_read_all_replies()
{
$this->signIn();
$thread = create('AppThread');
tap(auth()->user(), function ($user) use ($thread) {
$this->assertTrue($thread->hasUpdatesFor($user));
$user->read($thread);
$this->assertFalse($thread->hasUpdatesFor($user));
});
}
}
And running the test validates that the functionality is working as designed.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_thread_can_check_if_the_authenticated_user_has_read_all_replies PHPUnit 6.5.5 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 1.16 seconds, Memory: 8.00MB OK (1 test, 2 assertions)
How To Highlight New Content For Returning Visitors Summary
To be fair, this task could have been accomplished in many different ways. The same principle holds true however. If you want to highlight new content for visitors that are coming back to your website, you first need to record when the last time they checked on that particular content. Then, you set up logic to look at when that content has been updated versus when the user last viewed the content. Based on that logic, you’ll know whether to provide a nice visual cue to the user that there is new content to catch up on.