diff --git a/.env.example b/.env.example index 73d6512..eeecf5b 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null -BROADCAST_CONNECTION=log +BROADCAST_CONNECTION=reverb FILESYSTEM_DISK=local QUEUE_CONNECTION=database @@ -66,3 +66,18 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +REVERB_APP_ID=local +REVERB_APP_KEY=local-key +REVERB_APP_SECRET=local-secret +REVERB_SERVER_HOST=0.0.0.0 +REVERB_SERVER_PORT=8080 +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http +REVERB_ALLOWED_ORIGINS="localhost,127.0.0.1" + +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" diff --git a/app/Enums/RoomStateChange.php b/app/Enums/RoomStateChange.php new file mode 100644 index 0000000..53de42e --- /dev/null +++ b/app/Enums/RoomStateChange.php @@ -0,0 +1,15 @@ +roomId}"); + } + + public function broadcastAs(): string + { + return 'room.emote.thrown'; + } + + /** + * @return array{id: string, from_player_id: int, target_player_id: int} + */ + public function broadcastWith(): array + { + return [ + 'id' => $this->id, + 'from_player_id' => $this->fromPlayerId, + 'target_player_id' => $this->targetPlayerId, + ]; + } +} diff --git a/app/Events/RoomStateChanged.php b/app/Events/RoomStateChanged.php new file mode 100644 index 0000000..8d13377 --- /dev/null +++ b/app/Events/RoomStateChanged.php @@ -0,0 +1,42 @@ +roomId}"); + } + + public function broadcastAs(): string + { + return 'room.state.changed'; + } + + /** + * @return array{version: int, change: string} + */ + public function broadcastWith(): array + { + return [ + 'version' => $this->version, + 'change' => $this->change->value, + ]; + } +} diff --git a/app/Http/Controllers/RoomController.php b/app/Http/Controllers/RoomController.php index d8b0c65..a6a2e31 100644 --- a/app/Http/Controllers/RoomController.php +++ b/app/Http/Controllers/RoomController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Enums\RoomStateChange; +use App\Events\RoomStateChanged; use App\Http\Requests\StoreRoomRequest; use App\Models\Deck; use App\Models\Player; @@ -187,11 +189,13 @@ class RoomController extends Controller abort_if($room->trashed(), 404); abort_unless($user !== null && $user->id === $room->owner_id, 403); - $player = DB::transaction(function () use ($room, $user) { + [$player, $stateVersion, $wasReopened] = DB::transaction(function () use ($room, $user): array { $lockedRoom = Room::query()->lockForUpdate()->findOrFail($room->id); abort_unless($user->id === $lockedRoom->owner_id, 403); + $wasReopened = $lockedRoom->isClosed(); + if ($lockedRoom->isClosed()) { $lockedRoom->update([ 'current_round' => $lockedRoom->current_round + 1, @@ -202,13 +206,25 @@ class RoomController extends Controller ]); } - return $lockedRoom->players() - ->where('user_id', $user->id) - ->firstOrFail(); + return [ + $lockedRoom->players() + ->where('user_id', $user->id) + ->firstOrFail(), + $lockedRoom->state_version, + $wasReopened, + ]; }); $request->session()->put("room_players.{$room->id}", $player->id); + if ($wasReopened) { + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::RoomReopened, + ); + } + return to_route('room.show', $room); } @@ -219,7 +235,7 @@ class RoomController extends Controller abort_if($room->trashed(), 404); abort_unless($user !== null && $user->id === $room->owner_id, 403); - DB::transaction(function () use ($room, $user): void { + $stateVersion = DB::transaction(function () use ($room, $user): int { $lockedRoom = Room::withTrashed() ->lockForUpdate() ->findOrFail($room->id); @@ -231,9 +247,18 @@ class RoomController extends Controller 'state_version' => $lockedRoom->state_version + 1, ]); $lockedRoom->delete(); + + return $lockedRoom->state_version; }); $request->session()->forget("room_players.{$room->id}"); + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::RoomDeleted, + ); + Inertia::flash('toast', [ 'type' => 'success', 'message' => 'Der Raum wurde gelöscht.', diff --git a/app/Http/Controllers/RoomEmoteController.php b/app/Http/Controllers/RoomEmoteController.php new file mode 100644 index 0000000..1578838 --- /dev/null +++ b/app/Http/Controllers/RoomEmoteController.php @@ -0,0 +1,65 @@ +attributes->get('currentPlayer'); + + abort_unless( + $player instanceof Player && $player->room_id === $room->id, + 403, + ); + + $validated = $request->validate([ + 'target_player_id' => ['required', 'integer'], + ]); + $targetPlayerId = (int) $validated['target_player_id']; + + if ($targetPlayerId === $player->id) { + throw ValidationException::withMessages([ + 'target_player_id' => 'Du kannst dich nicht selbst abwerfen.', + ]); + } + + $targetExists = $room->players() + ->whereKey($targetPlayerId) + ->exists(); + + if (! $targetExists) { + throw ValidationException::withMessages([ + 'target_player_id' => 'Dieser Spieler sitzt nicht an diesem Tisch.', + ]); + } + + $rateLimitKey = "room-emote:{$room->id}:{$player->id}"; + + abort_if( + RateLimiter::tooManyAttempts($rateLimitKey, 10), + 429, + 'Bitte warte kurz, bevor du erneut wirfst.', + ); + + RateLimiter::hit($rateLimitKey, 10); + + PaperBallThrown::dispatch( + Str::uuid()->toString(), + $room->id, + $player->id, + $targetPlayerId, + ); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/RoomPlayerController.php b/app/Http/Controllers/RoomPlayerController.php index fbbdd4c..e664698 100644 --- a/app/Http/Controllers/RoomPlayerController.php +++ b/app/Http/Controllers/RoomPlayerController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Enums\RoomStateChange; +use App\Events\RoomStateChanged; use App\Http\Requests\JoinRoomRequest; use App\Models\Player; use App\Models\Room; @@ -54,6 +56,12 @@ class RoomPlayerController extends Controller $player->id, ); + RoomStateChanged::dispatch( + $room->id, + $room->state_version, + RoomStateChange::PlayerJoined, + ); + return to_route('room.show', $room); } diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index 04e6324..ca0d9b3 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Enums\RoomStateChange; +use App\Events\RoomStateChanged; use App\Models\Player; use App\Models\Room; use App\Models\Vote; @@ -25,7 +27,7 @@ class RoundController extends Controller 403, ); - DB::transaction(function () use ($room) { + $stateVersion = DB::transaction(function () use ($room): int { $lockedRoom = Room::query() ->lockForUpdate() ->findOrFail($room->id); @@ -42,8 +44,16 @@ class RoundController extends Controller 'last_activity_at' => now(), 'state_version' => $lockedRoom->state_version + 1, ]); + + return $lockedRoom->state_version; }); + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::VotesReset, + ); + return back(); } @@ -60,7 +70,7 @@ class RoundController extends Controller 403, ); - DB::transaction(function () use ($room) { + $stateVersion = DB::transaction(function () use ($room): int { $lockedRoom = Room::query() ->lockForUpdate() ->findOrFail($room->id); @@ -78,8 +88,16 @@ class RoundController extends Controller 'last_activity_at' => now(), 'state_version' => $lockedRoom->state_version + 1, ]); + + return $lockedRoom->state_version; }); + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::CardsRevealed, + ); + return back(); } @@ -96,7 +114,7 @@ class RoundController extends Controller 403, ); - DB::transaction(function () use ($room) { + $stateVersion = DB::transaction(function () use ($room): int { $lockedRoom = Room::query() ->lockForUpdate() ->findOrFail($room->id); @@ -115,8 +133,16 @@ class RoundController extends Controller 'last_activity_at' => now(), 'state_version' => $lockedRoom->state_version + 1, ]); + + return $lockedRoom->state_version; }); + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::NextRound, + ); + return back(); } diff --git a/app/Http/Controllers/VoteController.php b/app/Http/Controllers/VoteController.php index a0fc93c..3b2d70e 100644 --- a/app/Http/Controllers/VoteController.php +++ b/app/Http/Controllers/VoteController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Enums\RoomStateChange; +use App\Events\RoomStateChanged; use App\Http\Requests\SubmitVoteRequest; use App\Models\Player; use App\Models\Room; @@ -26,7 +28,7 @@ class VoteController extends Controller $value = $request->validated('value'); - DB::transaction(function () use ($room, $player, $value) { + $stateVersion = DB::transaction(function () use ($room, $player, $value): int { $lockedRoom = Room::query() ->lockForUpdate() ->findOrFail($room->id); @@ -58,8 +60,16 @@ class VoteController extends Controller 'last_activity_at' => now(), 'state_version' => $lockedRoom->state_version + 1, ]); + + return $lockedRoom->state_version; }); + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::VoteChanged, + ); + return back(); } } diff --git a/app/Jobs/CloseInactiveRooms.php b/app/Jobs/CloseInactiveRooms.php index 812fb99..6dcd355 100644 --- a/app/Jobs/CloseInactiveRooms.php +++ b/app/Jobs/CloseInactiveRooms.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Enums\RoomStateChange; +use App\Events\RoomStateChanged; use App\Models\Room; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -13,7 +15,7 @@ class CloseInactiveRooms implements ShouldQueue public function handle(): void { - $closedAt = now(); + $closedAt = now()->toImmutable(); $guestCutoff = $closedAt->subDays(config('rooms.guest_retention_days')); $accountCutoff = $closedAt->subDays(config('rooms.account_retention_days')); @@ -32,25 +34,40 @@ class CloseInactiveRooms implements ShouldQueue }) ->select('id') ->chunkById(100, function ($rooms) use ($closedAt, $guestCutoff, $accountCutoff): void { - Room::query() - ->whereKey($rooms->pluck('id')) - ->whereNull('closed_at') - ->where(function ($query) use ($guestCutoff, $accountCutoff): void { - $query - ->where(function ($query) use ($guestCutoff): void { - $query->whereNull('owner_id') - ->where('last_activity_at', '<=', $guestCutoff); - }) - ->orWhere(function ($query) use ($accountCutoff): void { - $query->whereNotNull('owner_id') - ->where('last_activity_at', '<=', $accountCutoff); - }); - }) - ->update([ - 'closed_at' => $closedAt, - 'state_version' => DB::raw('state_version + 1'), - 'updated_at' => $closedAt, - ]); + foreach ($rooms->pluck('id') as $roomId) { + $stateVersion = DB::transaction(function () use ($roomId, $closedAt, $guestCutoff, $accountCutoff): ?int { + $room = Room::query() + ->lockForUpdate() + ->find((string) $roomId); + + if ($room === null || $room->isClosed() || $room->last_activity_at === null) { + return null; + } + + $cutoff = $room->owner_id === null + ? $guestCutoff + : $accountCutoff; + + if ($room->last_activity_at->greaterThan($cutoff)) { + return null; + } + + $room->update([ + 'closed_at' => $closedAt, + 'state_version' => $room->state_version + 1, + ]); + + return $room->state_version; + }); + + if ($stateVersion !== null) { + RoomStateChanged::dispatch( + (string) $roomId, + $stateVersion, + RoomStateChange::RoomClosed, + ); + } + } }, 'id'); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..1edf4e3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Carbon\CarbonImmutable; +use Illuminate\Foundation\DevCommands; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\ServiceProvider; @@ -24,6 +25,10 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureDefaults(); + + if (app()->isLocal()) { + DevCommands::artisan('reverb:start --debug', 'reverb'); + } } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 7e9df36..c5a32de 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', + channels: __DIR__.'/../routes/channels.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { diff --git a/composer.json b/composer.json index bce162d..bc06793 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/chisel": "^0.1.0", "laravel/fortify": "^1.37.2", "laravel/framework": "^13.17", + "laravel/reverb": "^1.11", "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.14" }, @@ -116,4 +117,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index d6a85c8..d9a31b4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "859863f5ca81e4a2e3cf62f862513073", + "content-hash": "7f5e9a72c66a95e3c6cd86619f4c2c0c", "packages": [ { "name": "bacon/bacon-qr-code", @@ -189,6 +189,136 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "clue/redis-protocol", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/clue/redis-protocol.git", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\Redis\\Protocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.", + "homepage": "https://github.com/clue/redis-protocol", + "keywords": [ + "parser", + "protocol", + "redis", + "resp", + "serializer", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/redis-protocol/issues", + "source": "https://github.com/clue/redis-protocol/tree/v0.3.2" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-08-07T11:06:28+00:00" + }, + { + "name": "clue/redis-react", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-redis.git", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca", + "shasum": "" + }, + "require": { + "clue/redis-protocol": "^0.3.2", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.0 || ^1.1", + "react/promise-timer": "^1.11", + "react/socket": "^1.16" + }, + "require-dev": { + "clue/block-react": "^1.5", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Redis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Async Redis client implementation, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-redis", + "keywords": [ + "async", + "client", + "database", + "reactphp", + "redis" + ], + "support": { + "issues": "https://github.com/clue/reactphp-redis/issues", + "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2025-01-03T16:18:33+00:00" + }, { "name": "dasprid/enum", "version": "1.0.7", @@ -660,6 +790,53 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1752,6 +1929,85 @@ }, "time": "2026-08-04T14:50:50+00:00" }, + { + "name": "laravel/reverb", + "version": "v1.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/reverb.git", + "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/reverb/zipball/52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", + "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", + "shasum": "" + }, + "require": { + "clue/redis-react": "^2.6", + "guzzlehttp/psr7": "^2.6", + "illuminate/console": "^10.47|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.47|^11.0|^12.0|^13.0", + "illuminate/http": "^10.47|^11.0|^12.0|^13.0", + "illuminate/support": "^10.47|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", + "php": "^8.2", + "pusher/pusher-php-server": "^7.2", + "ratchet/rfc6455": "^0.4", + "react/promise-timer": "^1.10", + "react/socket": "^1.14", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.3|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "phpstan/phpstan": "^1.10", + "ratchet/pawl": "^0.4.1", + "react/async": "^4.2", + "react/http": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Reverb\\ApplicationManagerServiceProvider", + "Laravel\\Reverb\\ReverbServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Reverb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Joe Dixon", + "email": "joe@laravel.com" + } + ], + "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.", + "keywords": [ + "WebSockets", + "laravel", + "real-time", + "websocket" + ], + "support": { + "issues": "https://github.com/laravel/reverb/issues", + "source": "https://github.com/laravel/reverb/tree/v1.11.1" + }, + "time": "2026-08-06T14:48:58+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.15", @@ -3927,6 +4183,69 @@ }, "time": "2026-06-29T15:41:09+00:00" }, + { + "name": "pusher/pusher-php-server", + "version": "7.3.0", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "php": "^7.3|^8.0", + "psr/http-client": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.3.0" + }, + "time": "2026-08-07T12:47:11+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -4125,6 +4444,595 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "ratchet/rfc6455", + "version": "v0.4.1", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "psr/http-factory-implementation": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "guzzlehttp/psr7": "^2.7", + "phpunit/phpunit": "^9.5", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1" + }, + "time": "2026-06-06T14:34:23+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, { "name": "spomky-labs/cbor-php", "version": "3.3.0", diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..463d28b --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,34 @@ + env('BROADCAST_CONNECTION', 'null'), + + 'connections' => [ + + 'reverb' => [ + 'driver' => 'reverb', + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + 'path' => env('REVERB_SERVER_PATH', ''), + ], + 'client_options' => [], + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/config/reverb.php b/config/reverb.php new file mode 100644 index 0000000..34a2029 --- /dev/null +++ b/config/reverb.php @@ -0,0 +1,74 @@ + env('REVERB_SERVER', 'reverb'), + + 'servers' => [ + + 'reverb' => [ + 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'path' => env('REVERB_SERVER_PATH', ''), + 'hostname' => env('REVERB_HOST'), + 'options' => [ + 'tls' => [], + ], + 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'scaling' => [ + 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), + 'server' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', '0'), + 'timeout' => env('REDIS_TIMEOUT', 60), + ], + ], + 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), + 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), + ], + + ], + + 'apps' => [ + + 'provider' => 'config', + + 'apps' => [ + [ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => $allowedOrigins, + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'accept_client_events_from' => 'none', + 'rate_limiting' => [ + 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), + 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), + 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), + 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), + ], + ], + ], + + ], + +]; diff --git a/package.json b/package.json index 2736fa7..1984af2 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "@vueuse/core": "^12.8.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "laravel-echo": "^2.4.0", "laravel-vite-plugin": "^3.0.0", + "pusher-js": "^8.6.0", "reka-ui": "^2.9.8", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5a1899..7639ac2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,15 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + laravel-echo: + specifier: ^2.4.0 + version: 2.4.0(pusher-js@8.6.0) laravel-vite-plugin: specifier: ^3.0.0 version: 3.2.0(vite@8.2.1(@types/node@22.20.1)(jiti@2.7.0)) + pusher-js: + specifier: ^8.6.0 + version: 8.6.0 reka-ui: specifier: ^2.9.8 version: 2.10.3(vue@3.5.41(typescript@5.9.3)) @@ -1610,6 +1616,18 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + laravel-echo@2.4.0: + resolution: {integrity: sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==} + engines: {node: '>=20'} + peerDependencies: + pusher-js: '*' + socket.io-client: '*' + peerDependenciesMeta: + pusher-js: + optional: true + socket.io-client: + optional: true + laravel-precognition@2.0.0: resolution: {integrity: sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==} peerDependencies: @@ -2020,6 +2038,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pusher-js@8.6.0: + resolution: {integrity: sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2253,6 +2274,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4072,6 +4096,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + laravel-echo@2.4.0(pusher-js@8.6.0): + optionalDependencies: + pusher-js: 8.6.0 + laravel-precognition@2.0.0: dependencies: es-toolkit: 1.50.0 @@ -4360,6 +4388,10 @@ snapshots: punycode@2.3.1: {} + pusher-js@8.6.0: + dependencies: + tweetnacl: 1.0.3 + queue-microtask@1.2.3: {} react-reconciler@0.33.0(react@19.2.8): @@ -4653,6 +4685,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/resources/js/composables/usePaperBallToss.ts b/resources/js/composables/usePaperBallToss.ts new file mode 100644 index 0000000..21b71e6 --- /dev/null +++ b/resources/js/composables/usePaperBallToss.ts @@ -0,0 +1,194 @@ +import { nextTick, onScopeDispose, ref } from 'vue'; +import { toast } from 'vue-sonner'; + +import { useRoomEmotes } from '@/composables/useRoomEmotes'; +import { store as throwPaperBallRoute } from '@/routes/room/emote/paper-ball'; +import type { + PaperBallFlight, + PaperBallThrowError, + PaperBallThrownPayload, + PaperBallTossOptions, +} from '@/types/emote'; + +const PAPER_BALL_FLIGHT_DURATION = 780; +const SEEN_EVENT_RETENTION = 5_000; +const THROW_BUTTON_COOLDOWN = 550; + +export function usePaperBallToss({ + roomId, + currentPlayerId, + board, +}: PaperBallTossOptions) { + const activePaperBalls = ref([]); + const hitPlayerIds = ref([]); + const throwingAtPlayerId = ref(null); + const seenPaperBallIds = new Set(); + const timers = new Set(); + + function schedule(callback: () => void, delay: number): void { + const timer = window.setTimeout(() => { + timers.delete(timer); + callback(); + }, delay); + + timers.add(timer); + } + + async function throwPaperBall(targetPlayerId: number): Promise { + if ( + targetPlayerId === currentPlayerId() || + throwingAtPlayerId.value !== null + ) { + return; + } + + const csrfToken = document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute('content'); + + if (!csrfToken) { + toast.error('Der Papierknäuel konnte nicht geworfen werden.'); + + return; + } + + throwingAtPlayerId.value = targetPlayerId; + + try { + const response = await fetch(throwPaperBallRoute(roomId()).url, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + target_player_id: targetPlayerId, + }), + }); + + if (response.redirected) { + toast.error( + 'Bitte lade den Raum neu und versuche es noch einmal.', + ); + + return; + } + + if (!response.ok) { + const error = (await response + .json() + .catch(() => null)) as PaperBallThrowError | null; + const message = + error?.errors?.target_player_id?.[0] ?? + error?.message ?? + 'Der Papierknäuel konnte nicht geworfen werden.'; + + toast.error(message); + } + } catch { + toast.error('Der Papierknäuel konnte nicht geworfen werden.'); + } finally { + schedule(() => { + if (throwingAtPlayerId.value === targetPlayerId) { + throwingAtPlayerId.value = null; + } + }, THROW_BUTTON_COOLDOWN); + } + } + + async function animatePaperBall( + payload: PaperBallThrownPayload, + ): Promise { + if ( + typeof payload.id !== 'string' || + !Number.isInteger(payload.from_player_id) || + !Number.isInteger(payload.target_player_id) || + seenPaperBallIds.has(payload.id) + ) { + return; + } + + seenPaperBallIds.add(payload.id); + schedule( + () => seenPaperBallIds.delete(payload.id), + SEEN_EVENT_RETENTION, + ); + + await nextTick(); + + const gameBoard = board.value; + const fromAvatar = gameBoard?.querySelector( + `[data-player-avatar="${payload.from_player_id}"]`, + ); + const targetAvatar = gameBoard?.querySelector( + `[data-player-avatar="${payload.target_player_id}"]`, + ); + + if (!gameBoard || !fromAvatar || !targetAvatar) { + return; + } + + const boardRect = gameBoard.getBoundingClientRect(); + const fromRect = fromAvatar.getBoundingClientRect(); + const targetRect = targetAvatar.getBoundingClientRect(); + const startX = fromRect.left + fromRect.width / 2 - boardRect.left; + const startY = fromRect.top + fromRect.height / 2 - boardRect.top; + const travelX = + targetRect.left + targetRect.width / 2 - boardRect.left - startX; + const travelY = + targetRect.top + targetRect.height / 2 - boardRect.top - startY; + const distance = Math.hypot(travelX, travelY); + const arcHeight = Math.min(130, Math.max(55, distance * 0.22)); + + activePaperBalls.value.push({ + id: payload.id, + style: { + left: `${startX}px`, + top: `${startY}px`, + '--travel-quarter-x': `${travelX * 0.25}px`, + '--travel-quarter-y': `${travelY * 0.25 - arcHeight * 0.75}px`, + '--travel-mid-x': `${travelX * 0.5}px`, + '--travel-mid-y': `${travelY * 0.5 - arcHeight}px`, + '--travel-three-quarter-x': `${travelX * 0.75}px`, + '--travel-three-quarter-y': `${travelY * 0.75 - arcHeight * 0.75}px`, + '--travel-x': `${travelX}px`, + '--travel-y': `${travelY}px`, + }, + }); + + schedule(() => { + if (!hitPlayerIds.value.includes(payload.target_player_id)) { + hitPlayerIds.value.push(payload.target_player_id); + } + }, PAPER_BALL_FLIGHT_DURATION - 80); + + schedule(() => { + activePaperBalls.value = activePaperBalls.value.filter( + (paperBall) => paperBall.id !== payload.id, + ); + }, PAPER_BALL_FLIGHT_DURATION + 40); + + schedule(() => { + hitPlayerIds.value = hitPlayerIds.value.filter( + (playerId) => playerId !== payload.target_player_id, + ); + }, PAPER_BALL_FLIGHT_DURATION + 420); + } + + useRoomEmotes(roomId, animatePaperBall); + + onScopeDispose(() => { + timers.forEach((timer) => window.clearTimeout(timer)); + timers.clear(); + }); + + return { + activePaperBalls, + hitPlayerIds, + throwingAtPlayerId, + throwPaperBall, + }; +} diff --git a/resources/js/composables/useRoomEmotes.ts b/resources/js/composables/useRoomEmotes.ts new file mode 100644 index 0000000..7f185ca --- /dev/null +++ b/resources/js/composables/useRoomEmotes.ts @@ -0,0 +1,27 @@ +import { onScopeDispose, watch } from 'vue'; + +import { getEcho } from '@/lib/echo'; +import type { PaperBallThrownPayload } from '@/types/emote'; + +const PAPER_BALL_EVENT = '.room.emote.thrown'; + +export function useRoomEmotes( + roomId: () => string, + onPaperBallThrown: (payload: PaperBallThrownPayload) => void, +): void { + const stopWatchingRoom = watch( + roomId, + (nextRoomId, _previousRoomId, onCleanup) => { + const channel = getEcho().channel(`rooms.${nextRoomId}`); + + channel.listen(PAPER_BALL_EVENT, onPaperBallThrown); + + onCleanup(() => { + channel.stopListening(PAPER_BALL_EVENT, onPaperBallThrown); + }); + }, + { immediate: true }, + ); + + onScopeDispose(stopWatchingRoom); +} diff --git a/resources/js/composables/useRoomRealtime.ts b/resources/js/composables/useRoomRealtime.ts new file mode 100644 index 0000000..fd5b85a --- /dev/null +++ b/resources/js/composables/useRoomRealtime.ts @@ -0,0 +1,94 @@ +import { router } from '@inertiajs/vue3'; +import { onScopeDispose, watch } from 'vue'; + +import { getEcho } from '@/lib/echo'; +import type { RoomStateChangedPayload } from '@/types/room'; + +const ROOM_EVENT = '.room.state.changed'; + +export function useRoomRealtime( + roomId: () => string, + stateVersion: () => number, +): void { + let reloadInProgress = false; + let pendingVersion = 0; + let disposed = false; + + function reloadRoom(): void { + if (reloadInProgress || disposed) { + return; + } + + reloadInProgress = true; + + router.reload({ + only: ['room', 'currentPlayer'], + onFinish: () => { + reloadInProgress = false; + + if (disposed) { + return; + } + + if (pendingVersion > stateVersion()) { + reloadRoom(); + + return; + } + + pendingVersion = stateVersion(); + }, + }); + } + + function handleRoomChange(payload: RoomStateChangedPayload): void { + if ( + typeof payload.version !== 'number' || + payload.version <= Math.max(stateVersion(), pendingVersion) + ) { + return; + } + + pendingVersion = payload.version; + + if (payload.change === 'room_deleted') { + router.visit('/', { replace: true }); + + return; + } + + reloadRoom(); + } + + const stopWatchingRoom = watch( + roomId, + (nextRoomId, _previousRoomId, onCleanup) => { + const echo = getEcho(); + const channelName = `rooms.${nextRoomId}`; + + pendingVersion = stateVersion(); + echo.channel(channelName).listen(ROOM_EVENT, handleRoomChange); + + onCleanup(() => { + echo.leaveChannel(channelName); + }); + }, + { immediate: true }, + ); + + const stopWatchingVersion = watch( + stateVersion, + (version) => { + if (!reloadInProgress) { + pendingVersion = Math.max(pendingVersion, version); + } + }, + { flush: 'sync' }, + ); + + onScopeDispose(() => { + disposed = true; + stopWatchingRoom(); + stopWatchingVersion(); + }); +} diff --git a/resources/js/lib/echo.ts b/resources/js/lib/echo.ts new file mode 100644 index 0000000..57d16ff --- /dev/null +++ b/resources/js/lib/echo.ts @@ -0,0 +1,33 @@ +import Echo from 'laravel-echo'; +import Pusher from 'pusher-js'; + +let echoClient: Echo<'reverb'> | null = null; + +function environmentString(key: string): string | undefined { + const value = import.meta.env[key]; + + return typeof value === 'string' && value !== '' ? value : undefined; +} + +export function getEcho(): Echo<'reverb'> { + if (echoClient !== null) { + return echoClient; + } + + const scheme = environmentString('VITE_REVERB_SCHEME') ?? 'http'; + const forceTLS = scheme === 'https'; + + echoClient = new Echo<'reverb'>({ + broadcaster: 'reverb', + Pusher, + key: environmentString('VITE_REVERB_APP_KEY') ?? 'local-key', + wsHost: + environmentString('VITE_REVERB_HOST') ?? window.location.hostname, + wsPort: Number(environmentString('VITE_REVERB_PORT') ?? 8080), + wssPort: Number(environmentString('VITE_REVERB_PORT') ?? 443), + forceTLS, + enabledTransports: ['ws', 'wss'], + }); + + return echoClient; +} diff --git a/resources/js/pages/rooms/Show.vue b/resources/js/pages/rooms/Show.vue index 4d0d8ca..02db2f6 100644 --- a/resources/js/pages/rooms/Show.vue +++ b/resources/js/pages/rooms/Show.vue @@ -12,6 +12,8 @@ import { } from '@lucide/vue'; import { computed, ref, watch } from 'vue'; +import { usePaperBallToss } from '@/composables/usePaperBallToss'; +import { useRoomRealtime } from '@/composables/useRoomRealtime'; import { login, register } from '@/routes'; import { nextRound, reset as resetRound, reveal } from '@/routes/room'; import { store as storeVote } from '@/routes/room/vote'; @@ -60,8 +62,20 @@ const nextRoundForm = useForm<{ room?: string }>({}); const resetForm = useForm<{ room?: string }>({}); const copied = ref(false); const roomNoticeDismissed = ref(false); +const gameBoard = ref(null); +const { activePaperBalls, hitPlayerIds, throwingAtPlayerId, throwPaperBall } = + usePaperBallToss({ + roomId: () => props.room.id, + currentPlayerId: () => props.currentPlayer.id, + board: gameBoard, + }); -usePoll(2_000, { +useRoomRealtime( + () => props.room.id, + () => props.room.state_version, +); + +usePoll(30_000, { only: ['room', 'currentPlayer'], }); @@ -252,7 +266,11 @@ function playerVote(player: RoomPlayer): string | null {
-
+
+ +
+ +
@@ -528,6 +576,149 @@ function playerVote(player: RoomPlayer): string | null { overflow-wrap: anywhere; } +.throw-paper-ball { + position: absolute; + top: 0.65rem; + right: 0.35rem; + z-index: 8; + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border: 1px solid #64748b; + border-radius: 0.5rem; + background: #334155; + opacity: 0; + transform: translateY(0.25rem) rotate(-8deg); + pointer-events: none; + transition: + opacity 140ms ease, + transform 140ms ease, + background-color 140ms ease; +} + +.player-seat:hover .throw-paper-ball, +.player-seat:focus-within .throw-paper-ball { + opacity: 1; + transform: translateY(0) rotate(-8deg); + pointer-events: auto; +} + +.throw-paper-ball:hover:not(:disabled), +.throw-paper-ball:focus-visible { + background: #475569; + transform: translateY(-0.1rem) rotate(4deg); +} + +.throw-paper-ball:focus-visible { + outline: 2px solid #67e8f9; + outline-offset: 2px; +} + +.throw-paper-ball:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.throw-paper-ball.is-throwing { + opacity: 1; +} + +.paper-ball-mini, +.paper-ball { + display: block; + border: 1px solid rgb(100 116 139 / 65%); + border-radius: 48% 42% 54% 39%; + background: + linear-gradient( + 35deg, + transparent 44%, + rgb(100 116 139 / 35%) 46% 51%, + transparent 53% + ), + linear-gradient( + 120deg, + transparent 35%, + rgb(148 163 184 / 45%) 37% 42%, + transparent 44% + ), + #e2e8f0; +} + +.paper-ball-mini { + width: 0.95rem; + height: 0.95rem; + transform: rotate(18deg); +} + +.paper-ball-flight { + position: absolute; + z-index: 40; + width: 1.65rem; + height: 1.65rem; + pointer-events: none; + will-change: transform; + animation: paper-ball-flight 780ms linear forwards; +} + +.paper-ball { + width: 100%; + height: 100%; +} + +.player-seat.is-paper-ball-hit [data-player-avatar] { + animation: paper-ball-hit 380ms ease-out; +} + +@keyframes paper-ball-flight { + 0% { + transform: translate(-50%, -50%) translate(0, 0) rotate(0deg) + scale(0.72); + } + + 25% { + transform: translate(-50%, -50%) + translate(var(--travel-quarter-x), var(--travel-quarter-y)) + rotate(195deg) scale(0.92); + } + + 50% { + transform: translate(-50%, -50%) + translate(var(--travel-mid-x), var(--travel-mid-y)) rotate(390deg) + scale(1.08); + } + + 75% { + transform: translate(-50%, -50%) + translate( + var(--travel-three-quarter-x), + var(--travel-three-quarter-y) + ) + rotate(585deg) scale(1); + } + + 100% { + transform: translate(-50%, -50%) + translate(var(--travel-x), var(--travel-y)) rotate(780deg) + scale(0.82); + } +} + +@keyframes paper-ball-hit { + 0%, + 100% { + transform: translateX(0) rotate(0deg); + } + + 28% { + transform: translateX(-0.35rem) rotate(-7deg); + } + + 62% { + transform: translateX(0.25rem) rotate(5deg); + } +} + .player-details-above { transform: translateY(calc(-100% - 4.5rem)); } @@ -715,10 +906,25 @@ function playerVote(player: RoomPlayer): string | null { } } +@media (hover: none) { + .throw-paper-ball { + opacity: 0.85; + pointer-events: auto; + } +} + @media (prefers-reduced-motion: reduce) { .flip-card-inner, .deck-card { transition: none; } + + .paper-ball-flight { + animation-duration: 1ms; + } + + .player-seat.is-paper-ball-hit [data-player-avatar] { + animation: none; + } } diff --git a/resources/js/types/emote.ts b/resources/js/types/emote.ts new file mode 100644 index 0000000..6e3745a --- /dev/null +++ b/resources/js/types/emote.ts @@ -0,0 +1,34 @@ +import type { Ref, CSSProperties } from 'vue'; + +export type PaperBallThrownPayload = { + id: string; + from_player_id: number; + target_player_id: number; +}; + +export type PaperBallFlightStyle = CSSProperties & { + '--travel-quarter-x': string; + '--travel-quarter-y': string; + '--travel-mid-x': string; + '--travel-mid-y': string; + '--travel-three-quarter-x': string; + '--travel-three-quarter-y': string; + '--travel-x': string; + '--travel-y': string; +}; + +export type PaperBallFlight = { + id: string; + style: PaperBallFlightStyle; +}; + +export type PaperBallTossOptions = { + roomId: () => string; + currentPlayerId: () => number; + board: Ref; +}; + +export type PaperBallThrowError = { + message?: string; + errors?: Record; +}; diff --git a/resources/js/types/room.ts b/resources/js/types/room.ts index a7823a8..a66db39 100644 --- a/resources/js/types/room.ts +++ b/resources/js/types/room.ts @@ -13,6 +13,21 @@ export type RoomPlayer = { vote: string | null; }; +export type RoomStateChange = + | 'player_joined' + | 'vote_changed' + | 'cards_revealed' + | 'votes_reset' + | 'next_round' + | 'room_reopened' + | 'room_closed' + | 'room_deleted'; + +export type RoomStateChangedPayload = { + version: number; + change: RoomStateChange; +}; + export type SeatStyle = CSSProperties & { '--card-x': string; '--card-y': string; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 7083da4..b66a057 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -3,6 +3,7 @@ + {{-- Inline script to detect system dark mode preference and apply it immediately --}}