Redirect participants to meeting ended screen instead of 404.
Deploy Ladill Meet / deploy (push) Successful in 49s

Poll and waiting-room clients follow the same redirect when the host ends the call; chat and participants use mobile bottom sheets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 14:43:39 +00:00
co-authored by Cursor
parent 57356be9b6
commit 322f96b367
11 changed files with 398 additions and 35 deletions
+32 -4
View File
@@ -181,8 +181,15 @@ class JoinController extends Controller
{
$room->loadMissing('organization');
$session = $room->activeSession();
abort_unless($session?->isLive(), 404);
$session = $room->activeSession() ?? $this->participantSessionForRoom($request, $room);
if (! $session || ! $session->isLive()) {
if ($session) {
return redirect()->route('meet.ended', $session);
}
return redirect()->route('meet.join', $room);
}
$participant = $this->participantFromSession($request, $session);
@@ -204,8 +211,18 @@ class JoinController extends Controller
public function waitingStatus(Request $request, Room $room): JsonResponse
{
$session = $room->activeSession();
abort_unless($session, 404);
$session = $room->activeSession() ?? $this->participantSessionForRoom($request, $room);
if (! $session || ! $session->isLive()) {
if ($session) {
return response()->json([
'ended' => true,
'redirect' => route('meet.ended', $session),
]);
}
abort(404);
}
$participant = $this->participantFromSession($request, $session);
@@ -230,4 +247,15 @@ class JoinController extends Controller
->where('session_id', $session->id)
->first();
}
protected function participantSessionForRoom(Request $request, Room $room): ?Session
{
foreach ($room->sessions()->orderByDesc('started_at')->get() as $session) {
if ($request->session()->has("meet.participant.{$session->uuid}")) {
return $session;
}
}
return null;
}
}
@@ -77,4 +77,50 @@ class LeaveController extends Controller
'organization' => $session->room->organization,
]);
}
public function ended(Request $request, Session $session): View|RedirectResponse
{
$session->loadMissing('room.organization');
abort_unless($session->room, 404);
if ($session->isLive()) {
return redirect()->route('meet.room', $session);
}
$canGiveFeedback = $this->prepareFeedbackSession($request, $session);
return view('meet.ended.show', [
'session' => $session,
'room' => $session->room,
'organization' => $session->room->organization,
'canGiveFeedback' => $canGiveFeedback,
]);
}
public function prepareFeedbackSession(Request $request, Session $session): bool
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
if ($participantUuid) {
$participant = Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->first();
if ($participant) {
$request->session()->put('meet.left.feedback', [
'session_uuid' => $session->uuid,
'participant_uuid' => $participant->uuid,
'display_name' => $participant->display_name,
'user_ref' => $participant->user_ref,
]);
$request->session()->forget("meet.participant.{$session->uuid}");
return true;
}
}
$feedback = (array) $request->session()->get('meet.left.feedback', []);
return ($feedback['session_uuid'] ?? null) === $session->uuid;
}
}
@@ -30,7 +30,9 @@ class MeetingRoomController extends Controller
public function show(Request $request, Session $session): View|RedirectResponse
{
abort_unless($session->isLive(), 404, 'Meeting has ended.');
if (! $session->isLive()) {
return redirect()->route('meet.ended', $session);
}
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
abort_unless($participantUuid, 403, 'Please join the meeting first.');
@@ -156,6 +158,13 @@ class MeetingRoomController extends Controller
public function poll(Request $request, Session $session): JsonResponse
{
if (! $session->isLive()) {
return response()->json([
'ended' => true,
'redirect' => route('meet.ended', $session),
]);
}
$this->currentParticipant($request, $session);
$session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]);
+95
View File
@@ -72,6 +72,101 @@
}
}
/* Meeting room chat / participants: bottom sheet on mobile, sidebar on desktop */
.meet-room-panel {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.meet-room-panel__backdrop {
position: absolute;
inset: 0;
background-color: rgb(2 6 23 / 0.72);
backdrop-filter: blur(4px);
}
.meet-room-panel__sheet {
position: relative;
z-index: 1;
display: flex;
width: 100%;
max-height: min(85vh, 100%);
flex-direction: column;
border-radius: 1.5rem 1.5rem 0 0;
background-color: rgb(2 6 23);
padding: 0 0.75rem max(0.75rem, env(safe-area-inset-bottom, 0px));
}
.meet-room-panel__handle {
display: flex;
justify-content: center;
padding: 0.75rem 0 0.25rem;
}
.meet-room-panel__handle span {
display: block;
height: 0.25rem;
width: 2.5rem;
border-radius: 9999px;
background-color: rgb(51 65 85);
}
.meet-room-panel__header {
display: flex;
flex-shrink: 0;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0 0.25rem 0.75rem;
}
.meet-room-panel__body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
border-radius: 1rem;
background-color: rgb(15 23 42 / 0.9);
padding: 0.75rem;
}
@media (min-width: 640px) {
.meet-room-panel {
position: relative;
inset: auto;
z-index: auto;
display: flex;
height: 100%;
width: 20rem;
flex-shrink: 0;
justify-content: flex-start;
}
.meet-room-panel__backdrop,
.meet-room-panel__handle {
display: none;
}
.meet-room-panel__sheet {
display: flex;
height: 100%;
max-height: none;
flex: 1;
flex-direction: column;
border-radius: 0;
padding: 0.75rem;
}
.meet-room-panel__body {
border-radius: 1rem;
}
}
/* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */
@media (max-width: 1023px) {
.mobile-action-bar {
+5
View File
@@ -24,6 +24,11 @@ Alpine.data('waitingRoom', () => ({
const res = await fetch(url, { headers: { Accept: 'application/json' } });
const data = await res.json();
if (data.ended && data.redirect) {
window.location.href = data.redirect;
return;
}
if (data.redirect) {
window.location.href = data.redirect;
return;
+25
View File
@@ -73,6 +73,7 @@ function meetRoom() {
url: el.dataset.url,
configured: el.dataset.configured === '1',
pollUrl: el.dataset.pollUrl,
endedUrl: el.dataset.endedUrl,
chatUrl: el.dataset.chatUrl,
reactionUrl: el.dataset.reactionUrl,
raiseHandUrl: el.dataset.raiseHandUrl,
@@ -1044,7 +1045,18 @@ function meetRoom() {
async poll() {
try {
const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } });
if (!res.ok) {
if (res.status === 404 && this.config.endedUrl) {
this.redirectToMeetingEnded(this.config.endedUrl);
}
return;
}
const data = await res.json();
if (data.ended && data.redirect) {
this.redirectToMeetingEnded(data.redirect);
return;
}
if (data.participants) {
this.sessionParticipants = data.participants;
this.pruneTilesFromSession();
@@ -1067,6 +1079,19 @@ function meetRoom() {
}
},
redirectToMeetingEnded(url) {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (room) {
room.disconnect().catch(() => {});
}
window.location.href = url;
},
updateChatFromPoll(messages) {
const container = document.getElementById('chat-messages');
if (!container) return;
@@ -0,0 +1,48 @@
@props([
'show',
'title',
])
<aside x-show="{{ $show }}" x-cloak
class="meet-room-panel"
@keydown.escape.window="{{ $show }} = false">
<div x-show="{{ $show }}"
x-transition:enter="transition-opacity ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="meet-room-panel__backdrop"
@click="{{ $show }} = false"
aria-hidden="true"></div>
<div x-show="{{ $show }}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full sm:translate-y-0"
x-transition:enter-end="translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-full sm:translate-y-0"
class="meet-room-panel__sheet">
<div class="meet-room-panel__handle" aria-hidden="true"><span></span></div>
<div class="meet-room-panel__header">
<div class="min-w-0">
<h2 class="text-sm font-semibold text-white">{{ $title }}</h2>
@isset($subtitle)
<div class="text-xs text-slate-400">{{ $subtitle }}</div>
@endisset
</div>
<button @click="{{ $show }} = false" type="button"
class="shrink-0 rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
title="Close">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="meet-room-panel__body">
{{ $slot }}
</div>
</div>
</aside>
+33
View File
@@ -0,0 +1,33 @@
<x-meet-kiosk-layout :title="'Meeting ended · '.$room->title" :organization="$organization">
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-slate-100 text-slate-600">
<svg class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
</svg>
</div>
<h1 class="mt-6 text-2xl font-bold text-slate-900">Meeting ended</h1>
<p class="mt-3 text-sm text-slate-600">The host has ended this meeting.</p>
<p class="mt-1 text-sm font-medium text-slate-700">{{ $room->title }}</p>
<div class="mt-8 flex flex-col gap-3">
@if ($canGiveFeedback)
<a href="{{ route('meet.left', $session) }}" class="btn-primary btn-primary-lg py-3.5 font-semibold">
Share feedback
</a>
@endif
@auth
<a href="{{ route('meet.dashboard') }}"
class="{{ $canGiveFeedback ? 'btn-secondary py-3.5 font-semibold' : 'btn-primary btn-primary-lg py-3.5 font-semibold' }}">
Back to Meet
</a>
@else
<a href="{{ route('meet.join', $room) }}"
class="{{ $canGiveFeedback ? 'btn-secondary py-3.5 font-semibold' : 'btn-primary btn-primary-lg py-3.5 font-semibold' }}">
Return to join page
</a>
@endauth
</div>
</div>
</div>
</x-meet-kiosk-layout>
+15 -30
View File
@@ -33,6 +33,7 @@
data-display-name="{{ $participant->display_name }}"
data-is-host="{{ $participant->isHost() ? '1' : '0' }}"
data-poll-url="{{ route('meet.room.poll', $session) }}"
data-ended-url="{{ route('meet.ended', $session) }}"
data-chat-url="{{ route('meet.room.chat', $session) }}"
data-reaction-url="{{ route('meet.room.reaction', $session) }}"
data-raise-hand-url="{{ route('meet.room.raise-hand', $session) }}"
@@ -389,20 +390,12 @@
</div>
</div>
<aside x-show="chatOpen" x-cloak
class="flex h-full w-80 shrink-0 flex-col bg-slate-950 px-3 py-3">
<div class="flex shrink-0 items-center justify-between px-1 pb-3">
<div>
<h2 class="text-sm font-semibold text-white">Meeting chat</h2>
<p class="text-xs text-slate-400">Messages are visible to everyone in the call</p>
</div>
<button @click="chatOpen = false" type="button"
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
title="Close chat">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex min-h-0 flex-1 flex-col rounded-2xl bg-slate-900/90 p-3">
<x-meet.room.panel show="chatOpen" title="Meeting chat">
<x-slot:subtitle>
<p>Messages are visible to everyone in the call</p>
</x-slot:subtitle>
<div class="flex min-h-0 flex-1 flex-col">
<div class="flex-1 space-y-3 overflow-y-auto" id="chat-messages">
@foreach ($messages as $message)
@php $isOwn = $message->sender_name === $participant->display_name; @endphp
@@ -427,22 +420,14 @@
</div>
</form>
</div>
</aside>
</x-meet.room.panel>
<aside x-show="participantsOpen" x-cloak
class="flex h-full w-80 shrink-0 flex-col bg-slate-950 px-3 py-3">
<div class="flex shrink-0 items-center justify-between px-1 pb-3">
<div>
<h2 class="text-sm font-semibold text-white">Participants</h2>
<p class="text-xs text-slate-400" x-text="participantCount() + (participantCount() === 1 ? ' person' : ' people') + ' in this call'"></p>
</div>
<button @click="participantsOpen = false" type="button"
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
title="Close participants">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto rounded-2xl bg-slate-900/90 p-2">
<x-meet.room.panel show="participantsOpen" title="Participants">
<x-slot:subtitle>
<p x-text="participantCount() + (participantCount() === 1 ? ' person' : ' people') + ' in this call'"></p>
</x-slot:subtitle>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<template x-if="isHost && waitingParticipants().length">
<div class="mb-3 border-b border-slate-800 pb-3">
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-amber-300">
@@ -499,7 +484,7 @@
</template>
<p x-show="inCallParticipants().length === 0 && waitingParticipants().length === 0" class="px-2 py-4 text-center text-xs text-slate-500">No participants yet.</p>
</div>
</aside>
</x-meet.room.panel>
</div>
</body>
</html>
+1
View File
@@ -49,6 +49,7 @@ Route::post('/r/{room}/enter', [JoinController::class, 'enter'])->name('meet.joi
Route::get('/room/{session}/left', [LeaveController::class, 'show'])->name('meet.left');
Route::post('/room/{session}/left', [LeaveController::class, 'store'])->name('meet.left.feedback');
Route::get('/room/{session}/thanks', [LeaveController::class, 'thanks'])->name('meet.left.thanks');
Route::get('/room/{session}/ended', [LeaveController::class, 'ended'])->name('meet.ended');
Route::match(['get', 'post'], '/invite/{invitation}/rsvp', [InvitationController::class, 'rsvp'])->name('meet.invitations.rsvp');
+88
View File
@@ -251,6 +251,94 @@ class MeetWebTest extends TestCase
->assertRedirect(route('meet.left', $session));
}
public function test_participant_redirected_to_ended_page_when_meeting_ended(): void
{
$room = $this->createRoom(['settings' => array_merge(config('meet.default_settings'), ['waiting_room' => false])]);
$session = Session::create([
'owner_ref' => $this->user->public_id,
'room_id' => $room->id,
'media_room_name' => 'room-'.$room->uuid,
'status' => 'ended',
'started_at' => now()->subHour(),
'ended_at' => now(),
]);
$room->update(['status' => 'ended']);
$participant = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'display_name' => 'Guest',
'role' => 'guest',
'status' => 'left',
'joined_at' => now()->subHour(),
'left_at' => now(),
]);
$this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
->get('/room/'.$session->uuid)
->assertRedirect(route('meet.ended', $session));
}
public function test_poll_returns_ended_payload_when_meeting_ended(): void
{
$room = $this->createRoom();
$session = Session::create([
'owner_ref' => $this->user->public_id,
'room_id' => $room->id,
'media_room_name' => 'room-'.$room->uuid,
'status' => 'ended',
'started_at' => now()->subHour(),
'ended_at' => now(),
]);
$participant = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'display_name' => 'Guest',
'role' => 'guest',
'status' => 'left',
'joined_at' => now()->subHour(),
'left_at' => now(),
]);
$this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
->getJson('/room/'.$session->uuid.'/poll')
->assertOk()
->assertJson([
'ended' => true,
'redirect' => route('meet.ended', $session),
]);
}
public function test_waiting_guest_redirected_when_host_ends_meeting(): void
{
$room = $this->createRoom(['settings' => array_merge(config('meet.default_settings'), ['waiting_room' => true])]);
$session = Session::create([
'owner_ref' => $this->user->public_id,
'room_id' => $room->id,
'media_room_name' => 'room-'.$room->uuid,
'status' => 'ended',
'started_at' => now()->subHour(),
'ended_at' => now(),
]);
$room->update(['status' => 'ended']);
$participant = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'display_name' => 'Waiting Guest',
'role' => 'guest',
'status' => 'left',
'joined_at' => now()->subHour(),
'left_at' => now(),
]);
$this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
->getJson('/r/'.$room->uuid.'/waiting/status')
->assertOk()
->assertJson([
'ended' => true,
'redirect' => route('meet.ended', $session),
]);
}
public function test_authenticated_user_can_join_live_meeting_without_display_name(): void
{
$room = $this->createRoom();