Add live speaker invites for audio rooms and simplify the toolbar.
Deploy Ladill Meet / deploy (push) Successful in 1m9s

Let hosts promote raised-hand listeners from People, remove the empty More menu in spaces, and put People and recording on the bottom bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-04 00:40:09 +00:00
co-authored by Cursor
parent c51e59945e
commit 46af4f93b6
8 changed files with 270 additions and 3 deletions
@@ -252,6 +252,36 @@ class MeetingRoomController extends Controller
]); ]);
} }
public function promoteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
abort_unless($session->room->isSpace(), 404);
abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403);
$updated = app(SpaceService::class)->promoteToSpeaker($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref),
]);
}
public function demoteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
abort_unless($session->room->isSpace(), 404);
abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403);
$updated = app(SpaceService::class)->demoteFromSpeaker($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref),
]);
}
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); abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'chat'), 422);
+50
View File
@@ -71,4 +71,54 @@ class SpaceService
'lock', 'lock',
], true); ], true);
} }
public function promoteToSpeaker(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
abort_unless(in_array($participant->role, ['attendee', 'guest', 'participant'], true), 422);
$participant->update([
'role' => 'panelist',
'hand_raised' => false,
]);
if ($participant->user_ref) {
$speakerRefs = collect((array) $room->setting('speaker_refs', []))
->push($participant->user_ref)
->unique()
->values()
->all();
$room->update([
'settings' => array_merge($room->settings ?? [], [
'speaker_refs' => $speakerRefs,
]),
]);
}
return $participant->fresh();
}
public function demoteFromSpeaker(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
abort_unless($participant->role === 'panelist', 422);
$participant->update(['role' => 'attendee']);
if ($participant->user_ref) {
$speakerRefs = collect((array) $room->setting('speaker_refs', []))
->reject(fn (string $ref) => $ref === $participant->user_ref)
->values()
->all();
$room->update([
'settings' => array_merge($room->settings ?? [], [
'speaker_refs' => $speakerRefs,
]),
]);
}
return $participant->fresh();
}
} }
+63
View File
@@ -122,6 +122,8 @@ function meetRoom() {
speakGrantUrl: el.dataset.speakGrantUrl, speakGrantUrl: el.dataset.speakGrantUrl,
speakRevokeUrl: el.dataset.speakRevokeUrl, speakRevokeUrl: el.dataset.speakRevokeUrl,
speakDismissUrl: el.dataset.speakDismissUrl, speakDismissUrl: el.dataset.speakDismissUrl,
spacePromoteUrl: el.dataset.spacePromoteUrl,
spaceDemoteUrl: el.dataset.spaceDemoteUrl,
recordingStartUrl: el.dataset.recordingStartUrl, recordingStartUrl: el.dataset.recordingStartUrl,
recordingStopUrl: el.dataset.recordingStopUrl, recordingStopUrl: el.dataset.recordingStopUrl,
lockUrl: el.dataset.lockUrl, lockUrl: el.dataset.lockUrl,
@@ -2172,6 +2174,67 @@ function meetRoom() {
return this.inCallParticipants().filter((person) => person.speak_requested && !person.speak_granted); return this.inCallParticipants().filter((person) => person.speak_requested && !person.speak_granted);
}, },
handRaisedParticipants() {
return this.inCallParticipants().filter((person) => person.hand_raised && person.role === 'attendee');
},
spaceSpeakerUrl(template, participantUuid) {
return template?.replace('__UUID__', participantUuid) || '';
},
mergeSessionParticipant(participant) {
if (!participant?.uuid) {
return;
}
const index = this.sessionParticipants.findIndex((person) => person.uuid === participant.uuid);
if (index >= 0) {
this.sessionParticipants[index] = { ...this.sessionParticipants[index], ...participant };
}
if (participant.uuid === this.config.participantUuid) {
this.canPublish = ['host', 'co_host', 'panelist'].includes(participant.role);
}
},
async promoteSpaceSpeaker(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spacePromoteUrl, participantUuid);
if (!this.isSpace || !this.isHost || !url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
},
async demoteSpaceSpeaker(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spaceDemoteUrl, participantUuid);
if (!this.isSpace || !this.isHost || !url) {
return;
}
const res = await fetch(url, {
method: 'DELETE',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
},
syncSelfSpeakState() { syncSelfSpeakState() {
const self = this.sessionParticipants.find((person) => person.uuid === this.config.participantUuid); const self = this.sessionParticipants.find((person) => person.uuid === this.config.participantUuid);
if (!self) { if (!self) {
+62 -1
View File
@@ -82,6 +82,8 @@
data-speak-grant-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.grant', [$session, '__UUID__']) : '' }}" data-speak-grant-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.grant', [$session, '__UUID__']) : '' }}"
data-speak-revoke-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.revoke', [$session, '__UUID__']) : '' }}" data-speak-revoke-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.revoke', [$session, '__UUID__']) : '' }}"
data-speak-dismiss-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.dismiss', [$session, '__UUID__']) : '' }}" data-speak-dismiss-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.dismiss', [$session, '__UUID__']) : '' }}"
data-space-promote-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.promote', [$session, '__UUID__']) : '' }}"
data-space-demote-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.demote', [$session, '__UUID__']) : '' }}"
data-uses-speak-access="{{ ($usesSpeakAccess ?? false) ? '1' : '0' }}" data-uses-speak-access="{{ ($usesSpeakAccess ?? false) ? '1' : '0' }}"
data-can-manage-speak="{{ ($canManageSpeakAccess ?? false) ? '1' : '0' }}" data-can-manage-speak="{{ ($canManageSpeakAccess ?? false) ? '1' : '0' }}"
data-leave-url="{{ route('meet.room.leave', $session) }}" data-leave-url="{{ route('meet.room.leave', $session) }}"
@@ -422,7 +424,24 @@
title="React"> title="React">
@include('meet.room.partials.meet-icon', ['icon' => 'react']) @include('meet.room.partials.meet-icon', ['icon' => 'react'])
</button> </button>
<button @click="toggleParticipants()" type="button"
x-show="isSpace"
class="rounded-full p-3 transition-colors sm:hidden"
:class="sidebarPanel === 'participants' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="People">
<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="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></svg>
</button>
@if ($participant->isHost())
<button @click="toggleRecording()" type="button"
x-show="isSpace"
class="rounded-full p-3 transition-colors"
:class="isRecording ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
:title="isRecording ? 'Stop recording' : 'Start recording'">
<span class="block h-3 w-3 rounded-full" :class="isRecording ? 'bg-white animate-pulse' : 'bg-red-400'"></span>
</button>
@endif
<button @click="toggleFeatures()" type="button" <button @click="toggleFeatures()" type="button"
x-show="!isSpace"
class="rounded-full p-3 transition-colors sm:hidden" class="rounded-full p-3 transition-colors sm:hidden"
: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'"
title="More options"> title="More options">
@@ -450,12 +469,21 @@
<input type="file" id="meet-file-input" class="hidden" @change="uploadFile"> <input type="file" id="meet-file-input" class="hidden" @change="uploadFile">
@endunless @endunless
<button @click="toggleFeatures()" type="button" <button @click="toggleFeatures()" type="button"
x-show="!isSpace"
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'"
title="More options"> title="More options">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg> <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
More More
</button> </button>
@if (($isSpace ?? false) && $participant->isHost())
<button @click="toggleRecording()" type="button"
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
:class="isRecording ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
:title="isRecording ? 'Stop recording' : 'Start recording'">
<span class="block h-3 w-3 rounded-full" :class="isRecording ? 'bg-white animate-pulse' : 'bg-red-400'"></span>
</button>
@endif
<div class="meet-toolbar__divider sm:hidden" aria-hidden="true"></div> <div class="meet-toolbar__divider sm:hidden" aria-hidden="true"></div>
@@ -604,10 +632,31 @@
</template> </template>
</x-slot:subtitle> </x-slot:subtitle>
<x-slot:header> <x-slot:header>
<h2 class="text-base font-semibold text-white" x-text="usesStageLayout ? 'Speakers' : 'Participants'"></h2> <h2 class="text-base font-semibold text-white" x-text="isSpace ? 'People' : (usesStageLayout ? 'Speakers' : 'Participants')"></h2>
</x-slot:header> </x-slot:header>
<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">
<template x-if="isSpace && isHost && handRaisedParticipants().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">
Raised hands
<span class="ml-1 rounded-full bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-200"
x-text="handRaisedParticipants().length"></span>
</p>
<template x-for="person in handRaisedParticipants()" :key="'hand-'+person.uuid">
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-sm font-semibold text-amber-200"
x-text="participantInitials(person.display_name)"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-white" x-text="person.display_name"></span>
<span class="block text-xs text-amber-200/80">Wants to speak</span>
</span>
<button type="button" @click="promoteSpaceSpeaker(person.uuid)"
class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-emerald-500">Invite to speak</button>
</div>
</template>
</div>
</template>
<template x-if="isHost && waitingParticipants().length"> <template x-if="isHost && waitingParticipants().length">
<div class="mb-3 border-b border-slate-800 pb-3"> <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"> <p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-amber-300">
@@ -689,6 +738,16 @@
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500"> class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
Make speaker Make speaker
</button> </button>
<button x-show="isSpace && isHost && person.role === 'attendee'"
type="button" @click="promoteSpaceSpeaker(person.uuid)"
class="rounded-lg bg-emerald-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-emerald-500">
Invite to speak
</button>
<button x-show="isSpace && isHost && person.role === 'panelist'"
type="button" @click="demoteSpaceSpeaker(person.uuid)"
class="rounded-lg bg-slate-600 px-2 py-1 text-[10px] font-medium text-slate-100 hover:bg-slate-500">
Move to audience
</button>
<button x-show="canManageSpeak && usesSpeakAccess && person.role === 'attendee' && !person.speak_granted" <button x-show="canManageSpeak && usesSpeakAccess && person.role === 'attendee' && !person.speak_granted"
type="button" @click="grantSpeakAccess(person.uuid)" type="button" @click="grantSpeakAccess(person.uuid)"
class="rounded-lg bg-emerald-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-emerald-500"> class="rounded-lg bg-emerald-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-emerald-500">
@@ -829,6 +888,7 @@
</div> </div>
</div> </div>
@unless ($isSpace ?? false)
<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>{{ ($isSpace ?? false) ? 'Audio room controls' : $sessionNounTitle.' tools and extras' }}</p> <p>{{ ($isSpace ?? false) ? 'Audio room controls' : $sessionNounTitle.' tools and extras' }}</p>
@@ -972,6 +1032,7 @@
<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> <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>
@endunless
</div> </div>
</div> </div>
</body> </body>
+1 -1
View File
@@ -1,7 +1,7 @@
<x-app-layout title="Create room"> <x-app-layout title="Create room">
<div class="mx-auto max-w-xl"> <div class="mx-auto max-w-xl">
<h1 class="text-xl font-semibold text-slate-900">Create a room</h1> <h1 class="text-xl font-semibold text-slate-900">Create a room</h1>
<p class="mt-1 text-sm text-slate-500">Audio-only. Host and assigned speakers talk; everyone else listens and can raise a hand to speak.</p> <p class="mt-1 text-sm text-slate-500">Audio-only. Pick speakers when you create the room, or invite listeners to speak during the live session from People.</p>
<form method="POST" action="{{ route('meet.spaces.store') }}" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6"> <form method="POST" action="{{ route('meet.spaces.store') }}" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @csrf
+1 -1
View File
@@ -13,7 +13,7 @@
@endif @endif
<div class="mt-4 rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-900"> <div class="mt-4 rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
Host-led audio room. Assigned speakers can talk; everyone else listens and can request to speak. Host-led audio room. Assigned speakers can talk when the room opens; during a live session, open <strong>People</strong> to invite listeners who raised a hand.
@if ($room->setting('invite_only')) @if ($room->setting('invite_only'))
This room is private share the join link only with invited people. This room is private share the join link only with invited people.
@endif @endif
+2
View File
@@ -71,6 +71,8 @@ Route::post('/room/{session}/speak/cancel', [MeetingRoomController::class, 'canc
Route::post('/room/{session}/participants/{participant}/speak/grant', [MeetingRoomController::class, 'grantSpeak'])->name('meet.room.speak.grant'); Route::post('/room/{session}/participants/{participant}/speak/grant', [MeetingRoomController::class, 'grantSpeak'])->name('meet.room.speak.grant');
Route::post('/room/{session}/participants/{participant}/speak/revoke', [MeetingRoomController::class, 'revokeSpeak'])->name('meet.room.speak.revoke'); Route::post('/room/{session}/participants/{participant}/speak/revoke', [MeetingRoomController::class, 'revokeSpeak'])->name('meet.room.speak.revoke');
Route::post('/room/{session}/participants/{participant}/speak/dismiss', [MeetingRoomController::class, 'dismissSpeakRequest'])->name('meet.room.speak.dismiss'); Route::post('/room/{session}/participants/{participant}/speak/dismiss', [MeetingRoomController::class, 'dismissSpeakRequest'])->name('meet.room.speak.dismiss');
Route::post('/room/{session}/participants/{participant}/space/speaker', [MeetingRoomController::class, 'promoteSpaceSpeaker'])->name('meet.room.space.promote');
Route::delete('/room/{session}/participants/{participant}/space/speaker', [MeetingRoomController::class, 'demoteSpaceSpeaker'])->name('meet.room.space.demote');
Route::post('/room/{session}/chat', [MeetingRoomController::class, 'sendMessage'])->name('meet.room.chat'); Route::post('/room/{session}/chat', [MeetingRoomController::class, 'sendMessage'])->name('meet.room.chat');
Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReaction'])->name('meet.room.reaction'); Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReaction'])->name('meet.room.reaction');
Route::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll'); Route::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll');
+61
View File
@@ -1306,6 +1306,67 @@ class MeetWebTest extends TestCase
$response->assertDontSee('View the room lineup from Events', false); $response->assertDontSee('View the room lineup from Events', false);
} }
public function test_space_host_can_promote_listener_to_speaker(): 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,
'speaker_refs' => [],
]),
]);
$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(),
]);
$listener = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'user_ref' => 'listener-ref',
'display_name' => 'Listener',
'role' => 'attendee',
'status' => 'joined',
'hand_raised' => true,
'joined_at' => now(),
]);
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
->postJson('/room/'.$session->uuid.'/participants/'.$listener->uuid.'/space/speaker')
->assertOk()
->assertJsonPath('participant.role', 'panelist');
$listener->refresh();
$room->refresh();
$this->assertSame('panelist', $listener->role);
$this->assertFalse($listener->hand_raised);
$this->assertContains('listener-ref', (array) $room->setting('speaker_refs', []));
}
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([