diff --git a/app/Http/Controllers/Meet/MeetingFeaturesController.php b/app/Http/Controllers/Meet/MeetingFeaturesController.php index 571b688..b27ed40 100644 --- a/app/Http/Controllers/Meet/MeetingFeaturesController.php +++ b/app/Http/Controllers/Meet/MeetingFeaturesController.php @@ -16,6 +16,7 @@ use App\Services\Meet\LiveStreamService; use App\Services\Meet\PollService; use App\Services\Meet\QaService; use App\Services\Meet\SessionService; +use App\Services\Meet\SpaceService; use App\Services\Meet\StageLayoutService; use App\Services\Meet\TownHallService; use App\Services\Meet\WhiteboardService; @@ -42,6 +43,7 @@ class MeetingFeaturesController extends Controller public function submitQuestion(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'qa'); $participant = $this->participant($request, $session); $validated = $request->validate(['question' => ['required', 'string', 'max:1000']]); $question = $this->qa->submit($session, $participant, $validated['question']); @@ -51,6 +53,7 @@ class MeetingFeaturesController extends Controller public function approveQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse { + $this->abortUnlessRoomFeature($session, 'qa'); $this->hostOnly($request, $session); abort_unless($question->session_id === $session->id, 404); @@ -59,6 +62,7 @@ class MeetingFeaturesController extends Controller public function answerQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse { + $this->abortUnlessRoomFeature($session, 'qa'); $participant = $this->hostOnly($request, $session); abort_unless($question->session_id === $session->id, 404); $validated = $request->validate(['answer' => ['required', 'string', 'max:2000']]); @@ -70,6 +74,7 @@ class MeetingFeaturesController extends Controller public function upvoteQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse { + $this->abortUnlessRoomFeature($session, 'qa'); $this->participant($request, $session); abort_unless($question->session_id === $session->id, 404); @@ -80,6 +85,7 @@ class MeetingFeaturesController extends Controller public function createPoll(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'polls'); $this->hostOnly($request, $session); $validated = $request->validate([ 'question' => ['required', 'string', 'max:500'], @@ -94,6 +100,7 @@ class MeetingFeaturesController extends Controller public function votePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse { + $this->abortUnlessRoomFeature($session, 'polls'); $participant = $this->participant($request, $session); abort_unless($poll->session_id === $session->id, 404); $validated = $request->validate(['option_index' => ['required', 'integer', 'min:0']]); @@ -104,6 +111,7 @@ class MeetingFeaturesController extends Controller public function closePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse { + $this->abortUnlessRoomFeature($session, 'polls'); $this->hostOnly($request, $session); abort_unless($poll->session_id === $session->id, 404); @@ -114,6 +122,7 @@ class MeetingFeaturesController extends Controller public function createBreakouts(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'breakouts'); $this->hostOnly($request, $session); $validated = $request->validate([ 'count' => ['required', 'integer', 'min:1', 'max:20'], @@ -131,6 +140,7 @@ class MeetingFeaturesController extends Controller public function moveToBreakout(Request $request, Session $session, BreakoutRoom $breakout): JsonResponse { + $this->abortUnlessRoomFeature($session, 'breakouts'); $participant = $this->participant($request, $session); abort_unless($breakout->session_id === $session->id, 404); $this->breakouts->moveParticipant($participant, $breakout); @@ -140,6 +150,7 @@ class MeetingFeaturesController extends Controller public function returnFromBreakout(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'breakouts'); $participant = $this->participant($request, $session); $this->breakouts->returnToMain($participant); @@ -148,6 +159,7 @@ class MeetingFeaturesController extends Controller public function broadcastBreakout(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'breakouts'); $participant = $this->hostOnly($request, $session); $validated = $request->validate(['message' => ['required', 'string', 'max:1000']]); $message = $this->breakouts->broadcast($session, $validated['message'], $participant->display_name); @@ -157,6 +169,7 @@ class MeetingFeaturesController extends Controller public function closeBreakouts(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'breakouts'); $this->hostOnly($request, $session); $this->breakouts->closeAll($session); @@ -167,6 +180,7 @@ class MeetingFeaturesController extends Controller public function uploadFile(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'files'); $participant = $this->participant($request, $session); $validated = $request->validate(['file' => ['required', 'file', 'max:51200']]); @@ -199,6 +213,7 @@ class MeetingFeaturesController extends Controller public function showWhiteboard(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'whiteboard'); $this->participant($request, $session); $board = $this->whiteboards->ensureForSession($session); @@ -207,6 +222,7 @@ class MeetingFeaturesController extends Controller public function saveWhiteboard(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'whiteboard'); $this->participant($request, $session); $validated = $request->validate(['state' => ['required', 'array']]); $board = $this->whiteboards->ensureForSession($session); @@ -216,6 +232,7 @@ class MeetingFeaturesController extends Controller public function exportWhiteboard(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'whiteboard'); $this->participant($request, $session); $validated = $request->validate(['image_data' => ['required', 'string']]); $data = preg_replace('#^data:image/\w+;base64,#i', '', $validated['image_data']); @@ -232,6 +249,7 @@ class MeetingFeaturesController extends Controller public function configureLiveStream(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'live_stream'); $this->hostOnly($request, $session); $validated = $request->validate([ 'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))], @@ -247,6 +265,7 @@ class MeetingFeaturesController extends Controller public function startLiveStream(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'live_stream'); $this->hostOnly($request, $session); $validated = $request->validate([ 'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))], @@ -260,6 +279,7 @@ class MeetingFeaturesController extends Controller public function stopLiveStream(Request $request, Session $session): JsonResponse { + $this->abortUnlessRoomFeature($session, 'live_stream'); $this->hostOnly($request, $session); $validated = $request->validate([ 'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))], @@ -382,4 +402,13 @@ class MeetingFeaturesController extends Controller return $participant; } + + protected function abortUnlessRoomFeature(Session $session, string $feature): void + { + abort_unless( + app(SpaceService::class)->allowsRoomFeature($session->room, $feature), + 422, + 'This feature is not available in audio rooms.', + ); + } } diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 367f590..7537226 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -95,7 +95,7 @@ class MeetingRoomController extends Controller 'messages' => $this->chat->recentMessages($session), 'activeRecording' => $session->recordings()->where('status', 'recording')->first(), 'watermark' => $room->organization?->securitySetting('watermark_enabled', false), - 'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(), + 'isWebinar' => $room->isWebinar() || $room->isConference(), 'isConference' => $room->isConference(), 'isGreenRoom' => $session->session_mode === 'green_room', 'sessionMode' => $session->session_mode, @@ -254,6 +254,8 @@ class MeetingRoomController extends Controller public function sendMessage(Request $request, Session $session): JsonResponse { + abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'chat'), 422); + $participant = $this->currentParticipant($request, $session); $validated = $request->validate(['body' => ['required', 'string', 'max:2000']]); @@ -493,6 +495,8 @@ class MeetingRoomController extends Controller public function lock(Request $request, Session $session): JsonResponse { + abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'lock'), 422); + $participant = $this->currentParticipant($request, $session); abort_unless($participant->isHost(), 403); $this->sessions->lock($session, $participant->user_ref ?? $participant->uuid); @@ -502,6 +506,8 @@ class MeetingRoomController extends Controller public function unlock(Request $request, Session $session): JsonResponse { + abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'lock'), 422); + $participant = $this->currentParticipant($request, $session); abort_unless($participant->isHost(), 403); $this->sessions->unlock($session, $participant->user_ref ?? $participant->uuid); diff --git a/app/Services/Meet/SpaceService.php b/app/Services/Meet/SpaceService.php index 9de0549..7474c99 100644 --- a/app/Services/Meet/SpaceService.php +++ b/app/Services/Meet/SpaceService.php @@ -48,4 +48,27 @@ class SpaceService { return $room->isSpace() || (bool) $room->setting('audio_only', false); } + + /** + * Whether a live-room feature is available for this room type. + * Audio rooms (Spaces) mirror Twitter Spaces — no breakouts, chat, Q&A, etc. + */ + public function allowsRoomFeature(Room $room, string $feature): bool + { + if (! $room->isSpace()) { + return true; + } + + return ! in_array($feature, [ + 'breakouts', + 'chat', + 'qa', + 'polls', + 'files', + 'whiteboard', + 'programme', + 'live_stream', + 'lock', + ], true); + } } diff --git a/resources/css/app.css b/resources/css/app.css index 6d8cde6..fb50dba 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -26,6 +26,20 @@ html.app-shell { color-scheme: light; } +@media (max-width: 1023px) { + html.app-shell { + --ladill-mobile-nav-height: calc(4rem + env(safe-area-inset-bottom, 0px)); + } + + html.app-shell .ladill-mobile-main { + padding-bottom: calc(var(--ladill-mobile-nav-height) + 1rem); + } + + html.app-shell .ladill-mobile-bottom-nav__bar { + min-height: var(--ladill-mobile-nav-height); + } +} + html.app-shell :is(input, select, textarea) { color-scheme: light; } @@ -365,14 +379,22 @@ html.meet-room-page { } /* Meeting room footer */ +.meet-room-page .meet-room-footer { + min-height: 4.75rem; +} + @media (max-width: 639px) { + .meet-room-page .meet-room-footer { + min-height: calc(3.75rem + env(safe-area-inset-bottom, 0px)); + padding-bottom: max(0.75rem, env(safe-area-inset-bottom, 0px)); + } + .meet-room-page .meet-toolbar__track { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-inline: 1rem; - padding-bottom: max(0.25rem, env(safe-area-inset-bottom, 0px)); } .meet-room-page .meet-toolbar__primary { diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 4ca4a73..e207ddd 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -221,6 +221,10 @@ function meetRoom() { this.pollTimer = setInterval(() => this.poll(), 3000); this.applyStageLayout(); + + this.$nextTick(() => { + document.querySelector('.meet-toolbar__track')?.classList.add('is-ready'); + }); }, connectionErrorMessage(error) { @@ -1687,6 +1691,10 @@ function meetRoom() { }, async submitQuestion() { + if (this.isSpace) { + return; + } + const question = this.qaInput.trim(); if (!question || !this.config.qaUrl) return; this.qaInput = ''; @@ -1698,14 +1706,14 @@ function meetRoom() { }, async createBreakouts() { - if (!this.isHost || !this.config.breakoutsCreateUrl) return; + if (this.isSpace || !this.isHost || !this.config.breakoutsCreateUrl) return; this.breakoutCount = '4'; this.breakoutModalOpen = true; this.closeSidebar(); }, async submitBreakouts() { - if (!this.isHost || !this.config.breakoutsCreateUrl) return; + if (this.isSpace || !this.isHost || !this.config.breakoutsCreateUrl) return; const count = parseInt(this.breakoutCount, 10); if (!count || count < 1) return; @@ -1725,7 +1733,7 @@ function meetRoom() { }, async closeBreakouts() { - if (!this.isHost || !this.config.breakoutsCloseUrl) return; + if (this.isSpace || !this.isHost || !this.config.breakoutsCloseUrl) return; await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() }); this.closeSidebar(); @@ -1734,6 +1742,10 @@ function meetRoom() { }, async broadcastToBreakouts() { + if (this.isSpace) { + return; + } + const message = this.breakoutBroadcastInput.trim(); if (!this.isHost || !message || !this.config.breakoutsBroadcastUrl) { return; @@ -1855,10 +1867,18 @@ function meetRoom() { }, toggleProgramme() { + if (this.isSpace) { + return; + } + this.toggleSidebarPanel('programme'); }, openUploadPicker() { + if (this.isSpace) { + return; + } + document.getElementById('meet-file-input')?.click(); this.closeSidebar(); }, @@ -1869,6 +1889,10 @@ function meetRoom() { }, async toggleLockFromMenu() { + if (this.isSpace) { + return; + } + await this.toggleLock(); this.closeSidebar(); }, @@ -2112,10 +2136,14 @@ function meetRoom() { }, showVideoControl() { - return this.canPublish && !this.audioOnlyPublish; + return this.canPublish && !this.audioOnly && !this.audioOnlyPublish; }, showAudienceControls() { + if (this.isSpace) { + return !this.canPublish; + } + if (!this.usesSpeakAccess) { return this.canPublish; } @@ -2123,8 +2151,12 @@ function meetRoom() { return !this.canPublish || this.audioOnlyPublish; }, - /** Mobile primary toolbar — webinars/conferences only; meetings use More for extras. */ + /** Mobile primary toolbar — webinars/conferences and audio room listeners. */ showPrimaryToolbarAudienceControls() { + if (this.isSpace) { + return !this.canPublish; + } + if (!this.usesSpeakAccess) { return false; } @@ -2335,6 +2367,10 @@ function meetRoom() { }, toggleChat() { + if (this.isSpace) { + return; + } + this.toggleSidebarPanel('chat'); }, diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php index 64ac22c..8cab3c1 100644 --- a/resources/views/components/app-layout.blade.php +++ b/resources/views/components/app-layout.blade.php @@ -22,7 +22,7 @@
@include('partials.topbar', ['heading' => $heading ?? $title]) -
+
@include('partials.flash') {{ $slot }}
diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 5985d5f..2b5cd32 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -6,7 +6,10 @@ {{ $room->title }} · Ladill Meet @include('partials.favicon') - + @vite(['resources/css/app.css', 'resources/js/meet-room.js']) @@ -28,6 +31,7 @@ $isConferenceSession = $isConference ?? false; $sessionNoun = match (true) { $isConferenceSession => 'conference', + ($isSpace ?? false) => 'room', $room->isWebinar() => 'webinar', default => 'meeting', }; @@ -70,7 +74,7 @@ data-is-host="{{ ($canManageConference ?? $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-chat-url="{{ ($isSpace ?? false) ? '' : route('meet.room.chat', $session) }}" data-reaction-url="{{ route('meet.room.reaction', $session) }}" data-raise-hand-url="{{ route('meet.room.raise-hand', $session) }}" data-speak-request-url="{{ ($usesSpeakAccess ?? false) ? route('meet.room.speak.request', $session) : '' }}" @@ -84,8 +88,8 @@ data-end-url="{{ route('meet.room.end', $session) }}" data-recording-start-url="{{ route('meet.room.recording.start', $session) }}" data-recording-stop-url="{{ route('meet.room.recording.stop', $session) }}" - data-lock-url="{{ route('meet.room.lock', $session) }}" - data-unlock-url="{{ route('meet.room.unlock', $session) }}" + data-lock-url="{{ ($isSpace ?? false) ? '' : route('meet.room.lock', $session) }}" + data-unlock-url="{{ ($isSpace ?? false) ? '' : route('meet.room.unlock', $session) }}" data-caption-url="{{ route('meet.room.caption', $session) }}" data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}" data-mute-on-join="{{ $room->setting('mute_on_join', true) ? '1' : '0' }}" @@ -116,16 +120,16 @@ data-speak-granted="{{ ($speakGranted ?? false) ? '1' : '0' }}" data-speak-requested="{{ ($speakRequested ?? false) ? '1' : '0' }}" data-in-breakout="{{ ($inBreakout ?? false) ? '1' : '0' }}" - data-qa-url="{{ route('meet.room.qa.submit', $session) }}" - data-polls-create-url="{{ route('meet.room.polls.create', $session) }}" - data-breakouts-create-url="{{ route('meet.room.breakouts.create', $session) }}" - data-breakouts-close-url="{{ route('meet.room.breakouts.close', $session) }}" - data-breakouts-return-url="{{ route('meet.room.breakouts.return', $session) }}" - data-breakouts-broadcast-url="{{ route('meet.room.breakouts.broadcast', $session) }}" + data-qa-url="{{ ($isSpace ?? false) ? '' : route('meet.room.qa.submit', $session) }}" + data-polls-create-url="{{ ($isSpace ?? false) ? '' : route('meet.room.polls.create', $session) }}" + data-breakouts-create-url="{{ ($isSpace ?? false) ? '' : route('meet.room.breakouts.create', $session) }}" + data-breakouts-close-url="{{ ($isSpace ?? false) ? '' : route('meet.room.breakouts.close', $session) }}" + data-breakouts-return-url="{{ ($isSpace ?? false) ? '' : route('meet.room.breakouts.return', $session) }}" + data-breakouts-broadcast-url="{{ ($isSpace ?? false) ? '' : route('meet.room.breakouts.broadcast', $session) }}" data-media-token-url="{{ route('meet.room.media-token', $session) }}" - data-files-upload-url="{{ route('meet.room.files.upload', $session) }}" - data-whiteboard-url="{{ route('meet.room.whiteboard.show', $session) }}" - data-whiteboard-save-url="{{ route('meet.room.whiteboard.save', $session) }}" + data-files-upload-url="{{ ($isSpace ?? false) ? '' : route('meet.room.files.upload', $session) }}" + data-whiteboard-url="{{ ($isSpace ?? false) ? '' : route('meet.room.whiteboard.show', $session) }}" + data-whiteboard-save-url="{{ ($isSpace ?? false) ? '' : route('meet.room.whiteboard.save', $session) }}" data-join-url="{{ $room->joinUrl() }}" data-room-title="{{ $room->title }}" data-passcode="{{ $room->passcode ?? '' }}" @@ -224,7 +228,7 @@
-

Breakout session

@@ -233,7 +237,7 @@

-

Breakouts are active

@@ -341,7 +345,21 @@
-
+
+ @php + $toolbarShowMic = ($canPublish ?? true) && ((! ($audioOnlyPublish ?? false)) || ($speakGranted ?? false)); + $toolbarShowVideo = ($canPublish ?? true) && ! ($isAudioOnly ?? false) && ! ($audioOnlyPublish ?? false); + $toolbarShowAudiencePrimary = ($isSpace ?? false) + ? ! ($canPublish ?? true) + : (($usesSpeakAccess ?? false) && ((! ($canPublish ?? true)) || ($audioOnlyPublish ?? false))); + $toolbarShowAudienceDesktop = ($isSpace ?? false) + ? ! ($canPublish ?? true) + : (($usesSpeakAccess ?? false) + ? ((! ($canPublish ?? true)) || ($audioOnlyPublish ?? false)) + : ($canPublish ?? true)); + $toolbarShowReactPrimary = $toolbarShowAudiencePrimary + && ! (($usesSpeakAccess ?? false) && ($speakGranted ?? false) && ($audioOnlyPublish ?? false)); + @endphp

@@ -353,6 +371,8 @@

- - - + @unless ($isSpace ?? false) + @endunless
+ @unless ($isSpace ?? false)
@@ -479,6 +513,7 @@
+ @endunless + @unless ($isSpace ?? false)

Messages are visible to everyone in the call

@@ -534,7 +570,9 @@
+ @endunless + @unless ($isSpace ?? false)

Lineup from Events when linked to this {{ $sessionNoun }}

@@ -548,6 +586,7 @@ ])
+ @endunless @@ -792,22 +831,23 @@ -

{{ $sessionNounTitle }} tools and extras

+

{{ ($isSpace ?? false) ? 'Audio room controls' : $sessionNounTitle.' tools and extras' }}

@@ -846,10 +886,12 @@
+ @unless ($isSpace ?? false) @@ -863,6 +905,7 @@ Upload a file for everyone in the call + @endunless @if ($participant->isHost()) + @unless ($isSpace ?? false)
+ @endunless @endif @@ -924,7 +969,7 @@ @endif -

+

diff --git a/resources/views/partials/mobile-bottom-nav.blade.php b/resources/views/partials/mobile-bottom-nav.blade.php index a308a67..ba67075 100644 --- a/resources/views/partials/mobile-bottom-nav.blade.php +++ b/resources/views/partials/mobile-bottom-nav.blade.php @@ -17,8 +17,8 @@ } $navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600'; @endphp -
-