Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
74 lines
2.3 KiB
PHP
74 lines
2.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* Who else has a record's edit screen open right now.
|
|
*
|
|
* Advisory, not authoritative — there is no signal at all for "closed the
|
|
* tab", so presence really means "opened this within the last few minutes",
|
|
* which fades out on its own once nobody touches the screen again. That
|
|
* heuristic is the right shape for the problem: knowing that two people are
|
|
* mid-edit matters; an exact headcount does not.
|
|
*/
|
|
final class Presence
|
|
{
|
|
/**
|
|
* How long somebody stays "here" after they were last seen. Long enough
|
|
* to survive reading a long field without typing; short enough that a
|
|
* closed tab does not haunt a record for the rest of the day.
|
|
*/
|
|
protected const TTL_MINUTES = 10;
|
|
|
|
/**
|
|
* Record this user as here now, and answer who else already was.
|
|
*
|
|
* Reading and writing happen in one call because that is the one moment
|
|
* presence is ever needed: opening the screen. There is no separate
|
|
* "who is here" query anywhere else, so there is nothing for the two to
|
|
* disagree about.
|
|
*
|
|
* @return Collection<int, User>
|
|
*/
|
|
public static function touch(Model $record, User $user): Collection
|
|
{
|
|
$key = self::key($record);
|
|
$cutoff = now()->subMinutes(self::TTL_MINUTES)->timestamp;
|
|
|
|
$present = collect(self::entries($key))
|
|
->filter(fn (int $seenAt) => $seenAt >= $cutoff);
|
|
|
|
// Answered before this visit is recorded: nobody is ever told they
|
|
// are keeping themselves company.
|
|
$others = $present->keys()->reject(fn (int $id) => $id === $user->getKey());
|
|
|
|
$present[$user->getKey()] = now()->timestamp;
|
|
|
|
Cache::put($key, $present->all(), now()->addMinutes(self::TTL_MINUTES));
|
|
|
|
return $others->isEmpty()
|
|
? collect()
|
|
: User::query()->whereKey($others)->get();
|
|
}
|
|
|
|
protected static function key(Model $record): string
|
|
{
|
|
return 'presence:'.$record->getMorphClass().':'.$record->getKey();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, int>
|
|
*/
|
|
protected static function entries(string $key): array
|
|
{
|
|
return (array) Cache::get($key, []);
|
|
}
|
|
}
|