Limit audio rooms to Twitter Spaces features and fix live toolbar UX.
Deploy Ladill Meet / deploy (push) Successful in 1m3s
Deploy Ladill Meet / deploy (push) Successful in 1m3s
Block breakouts, chat, Q&A, and other meeting-only tools in spaces, hide video controls for audio-only rooms, and stabilize mobile nav and toolbar layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ use App\Services\Meet\LiveStreamService;
|
|||||||
use App\Services\Meet\PollService;
|
use App\Services\Meet\PollService;
|
||||||
use App\Services\Meet\QaService;
|
use App\Services\Meet\QaService;
|
||||||
use App\Services\Meet\SessionService;
|
use App\Services\Meet\SessionService;
|
||||||
|
use App\Services\Meet\SpaceService;
|
||||||
use App\Services\Meet\StageLayoutService;
|
use App\Services\Meet\StageLayoutService;
|
||||||
use App\Services\Meet\TownHallService;
|
use App\Services\Meet\TownHallService;
|
||||||
use App\Services\Meet\WhiteboardService;
|
use App\Services\Meet\WhiteboardService;
|
||||||
@@ -42,6 +43,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function submitQuestion(Request $request, Session $session): JsonResponse
|
public function submitQuestion(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'qa');
|
||||||
$participant = $this->participant($request, $session);
|
$participant = $this->participant($request, $session);
|
||||||
$validated = $request->validate(['question' => ['required', 'string', 'max:1000']]);
|
$validated = $request->validate(['question' => ['required', 'string', 'max:1000']]);
|
||||||
$question = $this->qa->submit($session, $participant, $validated['question']);
|
$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
|
public function approveQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'qa');
|
||||||
$this->hostOnly($request, $session);
|
$this->hostOnly($request, $session);
|
||||||
abort_unless($question->session_id === $session->id, 404);
|
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
|
public function answerQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'qa');
|
||||||
$participant = $this->hostOnly($request, $session);
|
$participant = $this->hostOnly($request, $session);
|
||||||
abort_unless($question->session_id === $session->id, 404);
|
abort_unless($question->session_id === $session->id, 404);
|
||||||
$validated = $request->validate(['answer' => ['required', 'string', 'max:2000']]);
|
$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
|
public function upvoteQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'qa');
|
||||||
$this->participant($request, $session);
|
$this->participant($request, $session);
|
||||||
abort_unless($question->session_id === $session->id, 404);
|
abort_unless($question->session_id === $session->id, 404);
|
||||||
|
|
||||||
@@ -80,6 +85,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function createPoll(Request $request, Session $session): JsonResponse
|
public function createPoll(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'polls');
|
||||||
$this->hostOnly($request, $session);
|
$this->hostOnly($request, $session);
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'question' => ['required', 'string', 'max:500'],
|
'question' => ['required', 'string', 'max:500'],
|
||||||
@@ -94,6 +100,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function votePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse
|
public function votePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'polls');
|
||||||
$participant = $this->participant($request, $session);
|
$participant = $this->participant($request, $session);
|
||||||
abort_unless($poll->session_id === $session->id, 404);
|
abort_unless($poll->session_id === $session->id, 404);
|
||||||
$validated = $request->validate(['option_index' => ['required', 'integer', 'min:0']]);
|
$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
|
public function closePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'polls');
|
||||||
$this->hostOnly($request, $session);
|
$this->hostOnly($request, $session);
|
||||||
abort_unless($poll->session_id === $session->id, 404);
|
abort_unless($poll->session_id === $session->id, 404);
|
||||||
|
|
||||||
@@ -114,6 +122,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function createBreakouts(Request $request, Session $session): JsonResponse
|
public function createBreakouts(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'breakouts');
|
||||||
$this->hostOnly($request, $session);
|
$this->hostOnly($request, $session);
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'count' => ['required', 'integer', 'min:1', 'max:20'],
|
'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
|
public function moveToBreakout(Request $request, Session $session, BreakoutRoom $breakout): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'breakouts');
|
||||||
$participant = $this->participant($request, $session);
|
$participant = $this->participant($request, $session);
|
||||||
abort_unless($breakout->session_id === $session->id, 404);
|
abort_unless($breakout->session_id === $session->id, 404);
|
||||||
$this->breakouts->moveParticipant($participant, $breakout);
|
$this->breakouts->moveParticipant($participant, $breakout);
|
||||||
@@ -140,6 +150,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function returnFromBreakout(Request $request, Session $session): JsonResponse
|
public function returnFromBreakout(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'breakouts');
|
||||||
$participant = $this->participant($request, $session);
|
$participant = $this->participant($request, $session);
|
||||||
$this->breakouts->returnToMain($participant);
|
$this->breakouts->returnToMain($participant);
|
||||||
|
|
||||||
@@ -148,6 +159,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function broadcastBreakout(Request $request, Session $session): JsonResponse
|
public function broadcastBreakout(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'breakouts');
|
||||||
$participant = $this->hostOnly($request, $session);
|
$participant = $this->hostOnly($request, $session);
|
||||||
$validated = $request->validate(['message' => ['required', 'string', 'max:1000']]);
|
$validated = $request->validate(['message' => ['required', 'string', 'max:1000']]);
|
||||||
$message = $this->breakouts->broadcast($session, $validated['message'], $participant->display_name);
|
$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
|
public function closeBreakouts(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'breakouts');
|
||||||
$this->hostOnly($request, $session);
|
$this->hostOnly($request, $session);
|
||||||
$this->breakouts->closeAll($session);
|
$this->breakouts->closeAll($session);
|
||||||
|
|
||||||
@@ -167,6 +180,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function uploadFile(Request $request, Session $session): JsonResponse
|
public function uploadFile(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'files');
|
||||||
$participant = $this->participant($request, $session);
|
$participant = $this->participant($request, $session);
|
||||||
$validated = $request->validate(['file' => ['required', 'file', 'max:51200']]);
|
$validated = $request->validate(['file' => ['required', 'file', 'max:51200']]);
|
||||||
|
|
||||||
@@ -199,6 +213,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function showWhiteboard(Request $request, Session $session): JsonResponse
|
public function showWhiteboard(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'whiteboard');
|
||||||
$this->participant($request, $session);
|
$this->participant($request, $session);
|
||||||
$board = $this->whiteboards->ensureForSession($session);
|
$board = $this->whiteboards->ensureForSession($session);
|
||||||
|
|
||||||
@@ -207,6 +222,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function saveWhiteboard(Request $request, Session $session): JsonResponse
|
public function saveWhiteboard(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'whiteboard');
|
||||||
$this->participant($request, $session);
|
$this->participant($request, $session);
|
||||||
$validated = $request->validate(['state' => ['required', 'array']]);
|
$validated = $request->validate(['state' => ['required', 'array']]);
|
||||||
$board = $this->whiteboards->ensureForSession($session);
|
$board = $this->whiteboards->ensureForSession($session);
|
||||||
@@ -216,6 +232,7 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
public function exportWhiteboard(Request $request, Session $session): JsonResponse
|
public function exportWhiteboard(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'whiteboard');
|
||||||
$this->participant($request, $session);
|
$this->participant($request, $session);
|
||||||
$validated = $request->validate(['image_data' => ['required', 'string']]);
|
$validated = $request->validate(['image_data' => ['required', 'string']]);
|
||||||
$data = preg_replace('#^data:image/\w+;base64,#i', '', $validated['image_data']);
|
$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
|
public function configureLiveStream(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'live_stream');
|
||||||
$this->hostOnly($request, $session);
|
$this->hostOnly($request, $session);
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
|
'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
|
public function startLiveStream(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'live_stream');
|
||||||
$this->hostOnly($request, $session);
|
$this->hostOnly($request, $session);
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
|
'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
|
public function stopLiveStream(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->abortUnlessRoomFeature($session, 'live_stream');
|
||||||
$this->hostOnly($request, $session);
|
$this->hostOnly($request, $session);
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
|
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
|
||||||
@@ -382,4 +402,13 @@ class MeetingFeaturesController extends Controller
|
|||||||
|
|
||||||
return $participant;
|
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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class MeetingRoomController extends Controller
|
|||||||
'messages' => $this->chat->recentMessages($session),
|
'messages' => $this->chat->recentMessages($session),
|
||||||
'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
|
'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
|
||||||
'watermark' => $room->organization?->securitySetting('watermark_enabled', false),
|
'watermark' => $room->organization?->securitySetting('watermark_enabled', false),
|
||||||
'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(),
|
'isWebinar' => $room->isWebinar() || $room->isConference(),
|
||||||
'isConference' => $room->isConference(),
|
'isConference' => $room->isConference(),
|
||||||
'isGreenRoom' => $session->session_mode === 'green_room',
|
'isGreenRoom' => $session->session_mode === 'green_room',
|
||||||
'sessionMode' => $session->session_mode,
|
'sessionMode' => $session->session_mode,
|
||||||
@@ -254,6 +254,8 @@ class MeetingRoomController extends Controller
|
|||||||
|
|
||||||
public function sendMessage(Request $request, Session $session): JsonResponse
|
public function sendMessage(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'chat'), 422);
|
||||||
|
|
||||||
$participant = $this->currentParticipant($request, $session);
|
$participant = $this->currentParticipant($request, $session);
|
||||||
|
|
||||||
$validated = $request->validate(['body' => ['required', 'string', 'max:2000']]);
|
$validated = $request->validate(['body' => ['required', 'string', 'max:2000']]);
|
||||||
@@ -493,6 +495,8 @@ class MeetingRoomController extends Controller
|
|||||||
|
|
||||||
public function lock(Request $request, Session $session): JsonResponse
|
public function lock(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'lock'), 422);
|
||||||
|
|
||||||
$participant = $this->currentParticipant($request, $session);
|
$participant = $this->currentParticipant($request, $session);
|
||||||
abort_unless($participant->isHost(), 403);
|
abort_unless($participant->isHost(), 403);
|
||||||
$this->sessions->lock($session, $participant->user_ref ?? $participant->uuid);
|
$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
|
public function unlock(Request $request, Session $session): JsonResponse
|
||||||
{
|
{
|
||||||
|
abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'lock'), 422);
|
||||||
|
|
||||||
$participant = $this->currentParticipant($request, $session);
|
$participant = $this->currentParticipant($request, $session);
|
||||||
abort_unless($participant->isHost(), 403);
|
abort_unless($participant->isHost(), 403);
|
||||||
$this->sessions->unlock($session, $participant->user_ref ?? $participant->uuid);
|
$this->sessions->unlock($session, $participant->user_ref ?? $participant->uuid);
|
||||||
|
|||||||
@@ -48,4 +48,27 @@ class SpaceService
|
|||||||
{
|
{
|
||||||
return $room->isSpace() || (bool) $room->setting('audio_only', false);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -26,6 +26,20 @@ html.app-shell {
|
|||||||
color-scheme: light;
|
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) {
|
html.app-shell :is(input, select, textarea) {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@@ -365,14 +379,22 @@ html.meet-room-page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Meeting room footer */
|
/* Meeting room footer */
|
||||||
|
.meet-room-page .meet-room-footer {
|
||||||
|
min-height: 4.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@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 {
|
.meet-room-page .meet-toolbar__track {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
padding-bottom: max(0.25rem, env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meet-room-page .meet-toolbar__primary {
|
.meet-room-page .meet-toolbar__primary {
|
||||||
|
|||||||
@@ -221,6 +221,10 @@ function meetRoom() {
|
|||||||
|
|
||||||
this.pollTimer = setInterval(() => this.poll(), 3000);
|
this.pollTimer = setInterval(() => this.poll(), 3000);
|
||||||
this.applyStageLayout();
|
this.applyStageLayout();
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
document.querySelector('.meet-toolbar__track')?.classList.add('is-ready');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
connectionErrorMessage(error) {
|
connectionErrorMessage(error) {
|
||||||
@@ -1687,6 +1691,10 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async submitQuestion() {
|
async submitQuestion() {
|
||||||
|
if (this.isSpace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const question = this.qaInput.trim();
|
const question = this.qaInput.trim();
|
||||||
if (!question || !this.config.qaUrl) return;
|
if (!question || !this.config.qaUrl) return;
|
||||||
this.qaInput = '';
|
this.qaInput = '';
|
||||||
@@ -1698,14 +1706,14 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createBreakouts() {
|
async createBreakouts() {
|
||||||
if (!this.isHost || !this.config.breakoutsCreateUrl) return;
|
if (this.isSpace || !this.isHost || !this.config.breakoutsCreateUrl) return;
|
||||||
this.breakoutCount = '4';
|
this.breakoutCount = '4';
|
||||||
this.breakoutModalOpen = true;
|
this.breakoutModalOpen = true;
|
||||||
this.closeSidebar();
|
this.closeSidebar();
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitBreakouts() {
|
async submitBreakouts() {
|
||||||
if (!this.isHost || !this.config.breakoutsCreateUrl) return;
|
if (this.isSpace || !this.isHost || !this.config.breakoutsCreateUrl) return;
|
||||||
|
|
||||||
const count = parseInt(this.breakoutCount, 10);
|
const count = parseInt(this.breakoutCount, 10);
|
||||||
if (!count || count < 1) return;
|
if (!count || count < 1) return;
|
||||||
@@ -1725,7 +1733,7 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async closeBreakouts() {
|
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() });
|
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
|
||||||
this.closeSidebar();
|
this.closeSidebar();
|
||||||
@@ -1734,6 +1742,10 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async broadcastToBreakouts() {
|
async broadcastToBreakouts() {
|
||||||
|
if (this.isSpace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const message = this.breakoutBroadcastInput.trim();
|
const message = this.breakoutBroadcastInput.trim();
|
||||||
if (!this.isHost || !message || !this.config.breakoutsBroadcastUrl) {
|
if (!this.isHost || !message || !this.config.breakoutsBroadcastUrl) {
|
||||||
return;
|
return;
|
||||||
@@ -1855,10 +1867,18 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggleProgramme() {
|
toggleProgramme() {
|
||||||
|
if (this.isSpace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.toggleSidebarPanel('programme');
|
this.toggleSidebarPanel('programme');
|
||||||
},
|
},
|
||||||
|
|
||||||
openUploadPicker() {
|
openUploadPicker() {
|
||||||
|
if (this.isSpace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('meet-file-input')?.click();
|
document.getElementById('meet-file-input')?.click();
|
||||||
this.closeSidebar();
|
this.closeSidebar();
|
||||||
},
|
},
|
||||||
@@ -1869,6 +1889,10 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async toggleLockFromMenu() {
|
async toggleLockFromMenu() {
|
||||||
|
if (this.isSpace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.toggleLock();
|
await this.toggleLock();
|
||||||
this.closeSidebar();
|
this.closeSidebar();
|
||||||
},
|
},
|
||||||
@@ -2112,10 +2136,14 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showVideoControl() {
|
showVideoControl() {
|
||||||
return this.canPublish && !this.audioOnlyPublish;
|
return this.canPublish && !this.audioOnly && !this.audioOnlyPublish;
|
||||||
},
|
},
|
||||||
|
|
||||||
showAudienceControls() {
|
showAudienceControls() {
|
||||||
|
if (this.isSpace) {
|
||||||
|
return !this.canPublish;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.usesSpeakAccess) {
|
if (!this.usesSpeakAccess) {
|
||||||
return this.canPublish;
|
return this.canPublish;
|
||||||
}
|
}
|
||||||
@@ -2123,8 +2151,12 @@ function meetRoom() {
|
|||||||
return !this.canPublish || this.audioOnlyPublish;
|
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() {
|
showPrimaryToolbarAudienceControls() {
|
||||||
|
if (this.isSpace) {
|
||||||
|
return !this.canPublish;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.usesSpeakAccess) {
|
if (!this.usesSpeakAccess) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2335,6 +2367,10 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggleChat() {
|
toggleChat() {
|
||||||
|
if (this.isSpace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.toggleSidebarPanel('chat');
|
this.toggleSidebarPanel('chat');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||||
@include('partials.topbar', ['heading' => $heading ?? $title])
|
@include('partials.topbar', ['heading' => $heading ?? $title])
|
||||||
<main class="flex-1 p-4 pb-24 sm:p-6 lg:pb-6">
|
<main class="flex-1 p-4 ladill-mobile-main sm:p-6 lg:pb-6">
|
||||||
@include('partials.flash')
|
@include('partials.flash')
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<title>{{ $room->title }} · Ladill Meet</title>
|
<title>{{ $room->title }} · Ladill Meet</title>
|
||||||
@include('partials.favicon')
|
@include('partials.favicon')
|
||||||
<style>[x-cloak]{display:none!important}</style>
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
.meet-toolbar__track:not(.is-ready) [data-toolbar-conditional] { display: none !important; }
|
||||||
|
</style>
|
||||||
@vite(['resources/css/app.css', 'resources/js/meet-room.js'])
|
@vite(['resources/css/app.css', 'resources/js/meet-room.js'])
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full overflow-hidden bg-slate-950 font-sans text-white" x-data="meetRoom()" x-init="init()">
|
<body class="h-full overflow-hidden bg-slate-950 font-sans text-white" x-data="meetRoom()" x-init="init()">
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
$isConferenceSession = $isConference ?? false;
|
$isConferenceSession = $isConference ?? false;
|
||||||
$sessionNoun = match (true) {
|
$sessionNoun = match (true) {
|
||||||
$isConferenceSession => 'conference',
|
$isConferenceSession => 'conference',
|
||||||
|
($isSpace ?? false) => 'room',
|
||||||
$room->isWebinar() => 'webinar',
|
$room->isWebinar() => 'webinar',
|
||||||
default => 'meeting',
|
default => 'meeting',
|
||||||
};
|
};
|
||||||
@@ -70,7 +74,7 @@
|
|||||||
data-is-host="{{ ($canManageConference ?? $participant->isHost()) ? '1' : '0' }}"
|
data-is-host="{{ ($canManageConference ?? $participant->isHost()) ? '1' : '0' }}"
|
||||||
data-poll-url="{{ route('meet.room.poll', $session) }}"
|
data-poll-url="{{ route('meet.room.poll', $session) }}"
|
||||||
data-ended-url="{{ route('meet.ended', $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-reaction-url="{{ route('meet.room.reaction', $session) }}"
|
||||||
data-raise-hand-url="{{ route('meet.room.raise-hand', $session) }}"
|
data-raise-hand-url="{{ route('meet.room.raise-hand', $session) }}"
|
||||||
data-speak-request-url="{{ ($usesSpeakAccess ?? false) ? route('meet.room.speak.request', $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-end-url="{{ route('meet.room.end', $session) }}"
|
||||||
data-recording-start-url="{{ route('meet.room.recording.start', $session) }}"
|
data-recording-start-url="{{ route('meet.room.recording.start', $session) }}"
|
||||||
data-recording-stop-url="{{ route('meet.room.recording.stop', $session) }}"
|
data-recording-stop-url="{{ route('meet.room.recording.stop', $session) }}"
|
||||||
data-lock-url="{{ route('meet.room.lock', $session) }}"
|
data-lock-url="{{ ($isSpace ?? false) ? '' : route('meet.room.lock', $session) }}"
|
||||||
data-unlock-url="{{ route('meet.room.unlock', $session) }}"
|
data-unlock-url="{{ ($isSpace ?? false) ? '' : route('meet.room.unlock', $session) }}"
|
||||||
data-caption-url="{{ route('meet.room.caption', $session) }}"
|
data-caption-url="{{ route('meet.room.caption', $session) }}"
|
||||||
data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}"
|
data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}"
|
||||||
data-mute-on-join="{{ $room->setting('mute_on_join', true) ? '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-granted="{{ ($speakGranted ?? false) ? '1' : '0' }}"
|
||||||
data-speak-requested="{{ ($speakRequested ?? false) ? '1' : '0' }}"
|
data-speak-requested="{{ ($speakRequested ?? false) ? '1' : '0' }}"
|
||||||
data-in-breakout="{{ ($inBreakout ?? false) ? '1' : '0' }}"
|
data-in-breakout="{{ ($inBreakout ?? false) ? '1' : '0' }}"
|
||||||
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
|
data-qa-url="{{ ($isSpace ?? false) ? '' : route('meet.room.qa.submit', $session) }}"
|
||||||
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
|
data-polls-create-url="{{ ($isSpace ?? false) ? '' : route('meet.room.polls.create', $session) }}"
|
||||||
data-breakouts-create-url="{{ route('meet.room.breakouts.create', $session) }}"
|
data-breakouts-create-url="{{ ($isSpace ?? false) ? '' : route('meet.room.breakouts.create', $session) }}"
|
||||||
data-breakouts-close-url="{{ route('meet.room.breakouts.close', $session) }}"
|
data-breakouts-close-url="{{ ($isSpace ?? false) ? '' : route('meet.room.breakouts.close', $session) }}"
|
||||||
data-breakouts-return-url="{{ route('meet.room.breakouts.return', $session) }}"
|
data-breakouts-return-url="{{ ($isSpace ?? false) ? '' : route('meet.room.breakouts.return', $session) }}"
|
||||||
data-breakouts-broadcast-url="{{ route('meet.room.breakouts.broadcast', $session) }}"
|
data-breakouts-broadcast-url="{{ ($isSpace ?? false) ? '' : route('meet.room.breakouts.broadcast', $session) }}"
|
||||||
data-media-token-url="{{ route('meet.room.media-token', $session) }}"
|
data-media-token-url="{{ route('meet.room.media-token', $session) }}"
|
||||||
data-files-upload-url="{{ route('meet.room.files.upload', $session) }}"
|
data-files-upload-url="{{ ($isSpace ?? false) ? '' : route('meet.room.files.upload', $session) }}"
|
||||||
data-whiteboard-url="{{ route('meet.room.whiteboard.show', $session) }}"
|
data-whiteboard-url="{{ ($isSpace ?? false) ? '' : route('meet.room.whiteboard.show', $session) }}"
|
||||||
data-whiteboard-save-url="{{ route('meet.room.whiteboard.save', $session) }}"
|
data-whiteboard-save-url="{{ ($isSpace ?? false) ? '' : route('meet.room.whiteboard.save', $session) }}"
|
||||||
data-join-url="{{ $room->joinUrl() }}"
|
data-join-url="{{ $room->joinUrl() }}"
|
||||||
data-room-title="{{ $room->title }}"
|
data-room-title="{{ $room->title }}"
|
||||||
data-passcode="{{ $room->passcode ?? '' }}"
|
data-passcode="{{ $room->passcode ?? '' }}"
|
||||||
@@ -224,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="inBreakout" x-cloak
|
<div x-show="inBreakout && !isSpace" x-cloak
|
||||||
class="flex shrink-0 flex-col gap-1 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
class="flex shrink-0 flex-col gap-1 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-semibold text-amber-100">Breakout session</p>
|
<p class="text-sm font-semibold text-amber-100">Breakout session</p>
|
||||||
@@ -233,7 +237,7 @@
|
|||||||
<p x-show="breakoutTimeRemaining()" class="shrink-0 text-xs font-medium text-amber-200/90" x-text="breakoutTimeRemaining()"></p>
|
<p x-show="breakoutTimeRemaining()" class="shrink-0 text-xs font-medium text-amber-200/90" x-text="breakoutTimeRemaining()"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="isHost && breakoutsActive && !inBreakout" x-cloak
|
<div x-show="isHost && breakoutsActive && !inBreakout && !isSpace" x-cloak
|
||||||
class="flex shrink-0 flex-col gap-2 border-b border-amber-500/25 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
class="flex shrink-0 flex-col gap-2 border-b border-amber-500/25 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0 text-sm text-amber-100">
|
<div class="min-w-0 text-sm text-amber-100">
|
||||||
<p class="font-medium">Breakouts are active</p>
|
<p class="font-medium">Breakouts are active</p>
|
||||||
@@ -341,7 +345,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="relative shrink-0 bg-slate-950 py-4 sm:px-4">
|
<footer class="relative shrink-0 bg-slate-950 py-4 sm:px-4 meet-room-footer">
|
||||||
|
@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
|
||||||
<div x-show="speakGranted && audioOnlyPublish" x-cloak
|
<div x-show="speakGranted && audioOnlyPublish" x-cloak
|
||||||
class="pointer-events-none absolute inset-x-0 -top-10 flex justify-center px-4 sm:-top-11">
|
class="pointer-events-none absolute inset-x-0 -top-10 flex justify-center px-4 sm:-top-11">
|
||||||
<p class="rounded-full bg-emerald-500/15 px-4 py-1.5 text-xs font-medium text-emerald-300 ring-1 ring-emerald-500/30">
|
<p class="rounded-full bg-emerald-500/15 px-4 py-1.5 text-xs font-medium text-emerald-300 ring-1 ring-emerald-500/30">
|
||||||
@@ -353,6 +371,8 @@
|
|||||||
<div class="meet-toolbar__primary sm:contents">
|
<div class="meet-toolbar__primary sm:contents">
|
||||||
<button @click="toggleMute()" type="button"
|
<button @click="toggleMute()" type="button"
|
||||||
x-show="showMicrophoneControl()"
|
x-show="showMicrophoneControl()"
|
||||||
|
data-toolbar-conditional
|
||||||
|
@unless($toolbarShowMic) hidden @endunless
|
||||||
class="rounded-full p-3 transition-colors"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
:title="isMuted ? 'Unmute' : 'Mute'">
|
:title="isMuted ? 'Unmute' : 'Mute'">
|
||||||
@@ -361,6 +381,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<button @click="toggleVideo()" type="button"
|
<button @click="toggleVideo()" type="button"
|
||||||
x-show="showVideoControl()"
|
x-show="showVideoControl()"
|
||||||
|
data-toolbar-conditional
|
||||||
|
@unless($toolbarShowVideo) hidden @endunless
|
||||||
class="rounded-full p-3 transition-colors"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
:title="isVideoOff ? 'Turn camera on' : 'Turn camera off'">
|
:title="isVideoOff ? 'Turn camera on' : 'Turn camera off'">
|
||||||
@@ -369,6 +391,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<button @click="toggleScreenShare()" type="button"
|
<button @click="toggleScreenShare()" type="button"
|
||||||
x-show="showVideoControl()"
|
x-show="showVideoControl()"
|
||||||
|
data-toolbar-conditional
|
||||||
|
@unless($toolbarShowVideo) hidden @endunless
|
||||||
class="rounded-full p-3 transition-colors"
|
class="rounded-full p-3 transition-colors"
|
||||||
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
title="Share screen">
|
title="Share screen">
|
||||||
@@ -376,18 +400,24 @@
|
|||||||
</button>
|
</button>
|
||||||
<button @click="raiseHand()" type="button"
|
<button @click="raiseHand()" type="button"
|
||||||
x-show="showPrimaryToolbarAudienceControls()"
|
x-show="showPrimaryToolbarAudienceControls()"
|
||||||
|
data-toolbar-conditional
|
||||||
|
@unless($toolbarShowAudiencePrimary) hidden @endunless
|
||||||
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
||||||
title="Raise hand">
|
title="Raise hand">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="sendReaction('thumbs')" type="button"
|
<button @click="sendReaction('thumbs')" type="button"
|
||||||
x-show="showPrimaryToolbarAudienceControls()"
|
x-show="showPrimaryToolbarAudienceControls()"
|
||||||
|
data-toolbar-conditional
|
||||||
|
@unless($toolbarShowAudiencePrimary) hidden @endunless
|
||||||
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
||||||
title="Applause — sound plays when others join in">
|
title="Applause — sound plays when others join in">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="sendReaction('heart')" type="button"
|
<button @click="sendReaction('heart')" type="button"
|
||||||
x-show="showPrimaryToolbarAudienceControls() && !(usesSpeakAccess && speakGranted && audioOnlyPublish)"
|
x-show="showPrimaryToolbarAudienceControls() && !(usesSpeakAccess && speakGranted && audioOnlyPublish)"
|
||||||
|
data-toolbar-conditional
|
||||||
|
@unless($toolbarShowReactPrimary) hidden @endunless
|
||||||
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
|
||||||
title="React">
|
title="React">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
||||||
@@ -400,22 +430,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="raiseHand()" type="button" x-show="showAudienceControls()" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="Raise hand">
|
<button @click="raiseHand()" type="button" x-show="showAudienceControls()" data-toolbar-conditional @unless($toolbarShowAudienceDesktop) hidden @endunless class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="Raise hand">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="sendReaction('thumbs')" type="button" x-show="showAudienceControls()" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="Applause — sound plays when others join in">
|
<button @click="sendReaction('thumbs')" type="button" x-show="showAudienceControls()" data-toolbar-conditional @unless($toolbarShowAudienceDesktop) hidden @endunless class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="Applause — sound plays when others join in">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="sendReaction('heart')" type="button" x-show="showAudienceControls()" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="React">
|
<button @click="sendReaction('heart')" type="button" x-show="showAudienceControls()" data-toolbar-conditional @unless($toolbarShowAudienceDesktop) hidden @endunless class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="React">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleChat()" type="button"
|
<button @click="toggleChat()" type="button"
|
||||||
|
x-show="!isSpace"
|
||||||
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
|
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
|
||||||
:class="sidebarPanel === 'chat' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="sidebarPanel === 'chat' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
title="Chat">
|
title="Chat">
|
||||||
@include('meet.room.partials.meet-icon', ['icon' => 'chat'])
|
@include('meet.room.partials.meet-icon', ['icon' => 'chat'])
|
||||||
</button>
|
</button>
|
||||||
|
@unless ($isSpace ?? false)
|
||||||
<input type="file" id="meet-file-input" class="hidden" @change="uploadFile">
|
<input type="file" id="meet-file-input" class="hidden" @change="uploadFile">
|
||||||
|
@endunless
|
||||||
<button @click="toggleFeatures()" type="button"
|
<button @click="toggleFeatures()" type="button"
|
||||||
class="hidden items-center gap-1.5 rounded-full px-3.5 py-3 text-xs font-medium transition-colors sm:inline-flex"
|
class="hidden items-center gap-1.5 rounded-full px-3.5 py-3 text-xs font-medium transition-colors sm:inline-flex"
|
||||||
:class="sidebarPanel === 'features' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
:class="sidebarPanel === 'features' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||||
@@ -458,6 +491,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
@unless ($isSpace ?? false)
|
||||||
<div x-show="breakoutModalOpen" x-cloak
|
<div x-show="breakoutModalOpen" x-cloak
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||||
@keydown.escape.window="breakoutModalOpen = false">
|
@keydown.escape.window="breakoutModalOpen = false">
|
||||||
@@ -479,6 +513,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endunless
|
||||||
|
|
||||||
<div x-show="endMeetingConfirmOpen" x-cloak
|
<div x-show="endMeetingConfirmOpen" x-cloak
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||||
@@ -503,6 +538,7 @@
|
|||||||
@click="closeSidebar()"
|
@click="closeSidebar()"
|
||||||
aria-hidden="true"></div>
|
aria-hidden="true"></div>
|
||||||
|
|
||||||
|
@unless ($isSpace ?? false)
|
||||||
<x-meet.room.panel show="sidebarPanel === 'chat'" title="{{ $sessionNounTitle }} chat">
|
<x-meet.room.panel show="sidebarPanel === 'chat'" title="{{ $sessionNounTitle }} chat">
|
||||||
<x-slot:subtitle>
|
<x-slot:subtitle>
|
||||||
<p>Messages are visible to everyone in the call</p>
|
<p>Messages are visible to everyone in the call</p>
|
||||||
@@ -534,7 +570,9 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</x-meet.room.panel>
|
</x-meet.room.panel>
|
||||||
|
@endunless
|
||||||
|
|
||||||
|
@unless ($isSpace ?? false)
|
||||||
<x-meet.room.panel show="sidebarPanel === 'programme'" title="Programme">
|
<x-meet.room.panel show="sidebarPanel === 'programme'" title="Programme">
|
||||||
<x-slot:subtitle>
|
<x-slot:subtitle>
|
||||||
<p>Lineup from Events when linked to this {{ $sessionNoun }}</p>
|
<p>Lineup from Events when linked to this {{ $sessionNoun }}</p>
|
||||||
@@ -548,6 +586,7 @@
|
|||||||
])
|
])
|
||||||
</div>
|
</div>
|
||||||
</x-meet.room.panel>
|
</x-meet.room.panel>
|
||||||
|
@endunless
|
||||||
|
|
||||||
<x-meet.room.panel show="sidebarPanel === 'participants'" :title="null">
|
<x-meet.room.panel show="sidebarPanel === 'participants'" :title="null">
|
||||||
<x-slot:subtitle>
|
<x-slot:subtitle>
|
||||||
@@ -792,22 +831,23 @@
|
|||||||
|
|
||||||
<x-meet.room.panel show="sidebarPanel === 'features'" title="More options">
|
<x-meet.room.panel show="sidebarPanel === 'features'" title="More options">
|
||||||
<x-slot:subtitle>
|
<x-slot:subtitle>
|
||||||
<p>{{ $sessionNounTitle }} tools and extras</p>
|
<p>{{ ($isSpace ?? false) ? 'Audio room controls' : $sessionNounTitle.' tools and extras' }}</p>
|
||||||
</x-slot:subtitle>
|
</x-slot:subtitle>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="space-y-1 sm:hidden" x-show="showAudienceControls()">
|
<div class="space-y-1 sm:hidden" x-show="showAudienceControls()">
|
||||||
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
|
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
|
||||||
|
x-show="!isSpace"
|
||||||
@click="toggleChat()" />
|
@click="toggleChat()" />
|
||||||
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Signal that you have something to share"
|
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Signal that you have something to share"
|
||||||
x-show="!usesSpeakAccess"
|
x-show="!usesSpeakAccess || isSpace"
|
||||||
@click="raiseHand(); closeSidebar()" />
|
@click="raiseHand(); closeSidebar()" />
|
||||||
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send applause — sound plays when others join in"
|
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send applause — sound plays when others join in"
|
||||||
x-show="!usesSpeakAccess"
|
x-show="!usesSpeakAccess || isSpace"
|
||||||
@click="sendReaction('thumbs'); closeSidebar()" />
|
@click="sendReaction('thumbs'); closeSidebar()" />
|
||||||
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
|
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
|
||||||
x-show="!usesSpeakAccess || (usesSpeakAccess && speakGranted && audioOnlyPublish)"
|
x-show="isSpace || !usesSpeakAccess || (usesSpeakAccess && speakGranted && audioOnlyPublish)"
|
||||||
@click="sendReaction('heart'); closeSidebar()" />
|
@click="sendReaction('heart'); closeSidebar()" />
|
||||||
<div class="my-1 border-t border-slate-800"></div>
|
<div class="my-1 border-t border-slate-800"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -846,10 +886,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 sm:hidden" x-show="showStagePublisherControls()">
|
<div class="space-y-1 sm:hidden" x-show="showStagePublisherControls()">
|
||||||
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
|
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
|
||||||
|
x-show="!isSpace"
|
||||||
@click="toggleChat()" />
|
@click="toggleChat()" />
|
||||||
<div class="my-1 border-t border-slate-800"></div>
|
<div class="my-1 border-t border-slate-800"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@unless ($isSpace ?? false)
|
||||||
<x-meet.room.menu-item icon="programme" title="Programme" :subtitle="'View the '.$sessionNoun.' lineup from Events'"
|
<x-meet.room.menu-item icon="programme" title="Programme" :subtitle="'View the '.$sessionNoun.' lineup from Events'"
|
||||||
@click="toggleProgramme()" />
|
@click="toggleProgramme()" />
|
||||||
|
|
||||||
@@ -863,6 +905,7 @@
|
|||||||
<span class="block text-xs text-slate-400">Upload a file for everyone in the call</span>
|
<span class="block text-xs text-slate-400">Upload a file for everyone in the call</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
@endunless
|
||||||
|
|
||||||
@if ($participant->isHost())
|
@if ($participant->isHost())
|
||||||
<button @click="toggleRecordingFromMenu()" type="button"
|
<button @click="toggleRecordingFromMenu()" type="button"
|
||||||
@@ -876,6 +919,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@unless ($isSpace ?? false)
|
||||||
<button @click="toggleLockFromMenu()" type="button"
|
<button @click="toggleLockFromMenu()" type="button"
|
||||||
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
|
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
|
||||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
|
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
|
||||||
@@ -901,6 +945,7 @@
|
|||||||
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">Send</button>
|
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@endunless
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -924,7 +969,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p x-show="breakoutBroadcast" class="mt-3 rounded-xl bg-amber-950/40 px-3 py-3 text-sm text-amber-100" x-text="breakoutBroadcast"></p>
|
<p x-show="breakoutBroadcast && !isSpace" class="mt-3 rounded-xl bg-amber-950/40 px-3 py-3 text-sm text-amber-100" x-text="breakoutBroadcast"></p>
|
||||||
</div>
|
</div>
|
||||||
</x-meet.room.panel>
|
</x-meet.room.panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
}
|
}
|
||||||
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
|
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
|
||||||
@endphp
|
@endphp
|
||||||
<div x-data="{ profileOpen: false }" class="lg:hidden">
|
<div x-data="{ profileOpen: false }" class="lg:hidden ladill-mobile-bottom-nav">
|
||||||
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white"
|
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white ladill-mobile-bottom-nav__bar"
|
||||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
|
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
|
||||||
<div class="grid h-16 {{ $gridCols }}">
|
<div class="grid h-16 {{ $gridCols }}">
|
||||||
<a href="{{ $homeUrl }}"
|
<a href="{{ $homeUrl }}"
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||||
@if ($unreadUrl)
|
@if ($unreadUrl)
|
||||||
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
|
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
|
||||||
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
|
class="pointer-events-none absolute -right-1 -top-1 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
|
||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[10px] font-medium">Notifications</span>
|
<span class="text-[10px] font-medium">Notifications</span>
|
||||||
|
|||||||
@@ -1214,6 +1214,98 @@ class MeetWebTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_space_host_cannot_create_breakouts(): void
|
||||||
|
{
|
||||||
|
$room = Room::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'host_user_ref' => $this->user->public_id,
|
||||||
|
'title' => 'Leadership space',
|
||||||
|
'type' => 'space',
|
||||||
|
'status' => 'live',
|
||||||
|
'scheduled_at' => now()->addHour(),
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => array_merge(config('meet.default_settings'), [
|
||||||
|
'audio_only' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = Session::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'media_room_name' => 'meet-'.$room->uuid,
|
||||||
|
'status' => 'live',
|
||||||
|
'session_mode' => 'live',
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$host = \App\Models\Participant::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'display_name' => 'Host',
|
||||||
|
'role' => 'host',
|
||||||
|
'status' => 'joined',
|
||||||
|
'joined_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
|
||||||
|
->postJson('/room/'.$session->uuid.'/breakouts', ['count' => 2])
|
||||||
|
->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_space_live_room_view_omits_meeting_only_features(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'meet.media.livekit.api_key' => 'APIxxxxxxxxxxxxxxxx',
|
||||||
|
'meet.media.livekit.api_secret' => 'secretsecretsecretsecretsecret12',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$room = Room::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'host_user_ref' => $this->user->public_id,
|
||||||
|
'title' => 'Leadership space',
|
||||||
|
'type' => 'space',
|
||||||
|
'status' => 'live',
|
||||||
|
'scheduled_at' => now()->addHour(),
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'settings' => array_merge(config('meet.default_settings'), [
|
||||||
|
'audio_only' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = Session::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'media_room_name' => 'meet-'.$room->uuid,
|
||||||
|
'status' => 'live',
|
||||||
|
'session_mode' => 'live',
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$host = \App\Models\Participant::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'user_ref' => $this->user->public_id,
|
||||||
|
'display_name' => 'Host',
|
||||||
|
'role' => 'host',
|
||||||
|
'status' => 'joined',
|
||||||
|
'joined_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
|
||||||
|
->get('/room/'.$session->uuid);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('data-is-space="1"', false);
|
||||||
|
$response->assertDontSee('Start breakouts', false);
|
||||||
|
$response->assertDontSee('Share files', false);
|
||||||
|
$response->assertDontSee('View the room lineup from Events', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_breakouts_exclude_host_and_enable_meeting_mode_for_attendees(): void
|
public function test_breakouts_exclude_host_and_enable_meeting_mode_for_attendees(): void
|
||||||
{
|
{
|
||||||
config([
|
config([
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Services\Meet\SpaceService;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SpaceServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_space_rooms_disallow_meeting_only_features(): void
|
||||||
|
{
|
||||||
|
$service = app(SpaceService::class);
|
||||||
|
$space = new Room(['type' => 'space']);
|
||||||
|
$meeting = new Room(['type' => 'meeting']);
|
||||||
|
|
||||||
|
foreach (['breakouts', 'chat', 'qa', 'polls', 'files', 'whiteboard', 'programme', 'live_stream', 'lock'] as $feature) {
|
||||||
|
$this->assertFalse($service->allowsRoomFeature($space, $feature), $feature);
|
||||||
|
$this->assertTrue($service->allowsRoomFeature($meeting, $feature), $feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user