diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index cb9e7a7..128deb6 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -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; + } } diff --git a/app/Http/Controllers/Meet/LeaveController.php b/app/Http/Controllers/Meet/LeaveController.php index d1f6e7f..69f9022 100644 --- a/app/Http/Controllers/Meet/LeaveController.php +++ b/app/Http/Controllers/Meet/LeaveController.php @@ -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; + } } diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 57f5e36..4621c5c 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -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'])]); diff --git a/resources/css/app.css b/resources/css/app.css index c637f93..7568394 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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 { diff --git a/resources/js/meet-public.js b/resources/js/meet-public.js index 5fe419c..41932e2 100644 --- a/resources/js/meet-public.js +++ b/resources/js/meet-public.js @@ -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; diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 15b7aee..be3abe0 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -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; diff --git a/resources/views/components/meet/room/panel.blade.php b/resources/views/components/meet/room/panel.blade.php new file mode 100644 index 0000000..9bdc882 --- /dev/null +++ b/resources/views/components/meet/room/panel.blade.php @@ -0,0 +1,48 @@ +@props([ + 'show', + 'title', +]) + + diff --git a/resources/views/meet/ended/show.blade.php b/resources/views/meet/ended/show.blade.php new file mode 100644 index 0000000..5a0aabd --- /dev/null +++ b/resources/views/meet/ended/show.blade.php @@ -0,0 +1,33 @@ + + + + + + + + + Meeting ended + The host has ended this meeting. + {{ $room->title }} + + + @if ($canGiveFeedback) + + Share feedback + + @endif + @auth + + Back to Meet + + @else + + Return to join page + + @endauth + + + + diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 61ab57f..7658b61 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -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 @@ -
The host has ended this meeting.
{{ $room->title }}