Require acceptance for space speak invites and add instant co-host promotion.
Deploy Ladill Meet / deploy (push) Successful in 40s

Remove duplicate host record button on desktop and split invite-to-speak from make co-host so listeners must accept before speaking while co-host is applied immediately.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-04 00:50:58 +00:00
co-authored by Cursor
parent 46af4f93b6
commit 2fae2e7ed8
6 changed files with 338 additions and 14 deletions
@@ -252,14 +252,70 @@ class MeetingRoomController extends Controller
]);
}
public function promoteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse
public function inviteSpaceSpeaker(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);
$updated = app(SpaceService::class)->inviteToSpeak($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 acceptSpaceSpeakInvitation(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($session->room->isSpace(), 404);
$updated = app(SpaceService::class)->acceptSpeakInvitation($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 declineSpaceSpeakInvitation(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($session->room->isSpace(), 404);
$updated = app(SpaceService::class)->declineSpeakInvitation($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 dismissSpaceSpeakInvitation(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)->dismissSpeakInvitation($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 promoteSpaceCoHost(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)->promoteToCoHost($session->room, $participant);
$avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref);
return response()->json([
+59 -2
View File
@@ -72,6 +72,49 @@ class SpaceService
], true);
}
public function inviteToSpeak(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
abort_unless(in_array($participant->role, ['attendee', 'guest', 'participant'], true), 422);
abort_unless(! $participant->speak_requested, 422, 'An invitation is already pending.');
$participant->update([
'speak_requested' => true,
'hand_raised' => false,
]);
return $participant->fresh();
}
public function acceptSpeakInvitation(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
abort_unless(in_array($participant->role, ['attendee', 'guest', 'participant'], true), 422);
abort_unless($participant->speak_requested, 422, 'No speak invitation to accept.');
return $this->promoteToSpeaker($room, $participant);
}
public function declineSpeakInvitation(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
abort_unless($participant->speak_requested, 422);
$participant->update(['speak_requested' => false]);
return $participant->fresh();
}
public function dismissSpeakInvitation(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
abort_unless($participant->speak_requested, 422);
$participant->update(['speak_requested' => false]);
return $participant->fresh();
}
public function promoteToSpeaker(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
@@ -80,6 +123,7 @@ class SpaceService
$participant->update([
'role' => 'panelist',
'hand_raised' => false,
'speak_requested' => false,
]);
if ($participant->user_ref) {
@@ -99,12 +143,25 @@ class SpaceService
return $participant->fresh();
}
public function demoteFromSpeaker(Room $room, Participant $participant): Participant
public function promoteToCoHost(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
abort_unless($participant->role === 'panelist', 422);
$participant->update(['role' => 'attendee']);
$participant->update(['role' => 'co_host']);
return $participant->fresh();
}
public function demoteFromSpeaker(Room $room, Participant $participant): Participant
{
abort_unless($room->isSpace(), 422);
abort_unless(in_array($participant->role, ['panelist', 'co_host'], true), 422);
$participant->update([
'role' => 'attendee',
'speak_requested' => false,
]);
if ($participant->user_ref) {
$speakerRefs = collect((array) $room->setting('speaker_refs', []))
+101 -2
View File
@@ -124,6 +124,10 @@ function meetRoom() {
speakDismissUrl: el.dataset.speakDismissUrl,
spacePromoteUrl: el.dataset.spacePromoteUrl,
spaceDemoteUrl: el.dataset.spaceDemoteUrl,
spaceSpeakAcceptUrl: el.dataset.spaceSpeakAcceptUrl,
spaceSpeakDeclineUrl: el.dataset.spaceSpeakDeclineUrl,
spaceSpeakDismissUrl: el.dataset.spaceSpeakDismissUrl,
spaceCoHostUrl: el.dataset.spaceCoHostUrl,
recordingStartUrl: el.dataset.recordingStartUrl,
recordingStopUrl: el.dataset.recordingStopUrl,
lockUrl: el.dataset.lockUrl,
@@ -2175,7 +2179,18 @@ function meetRoom() {
},
handRaisedParticipants() {
return this.inCallParticipants().filter((person) => person.hand_raised && person.role === 'attendee');
return this.inCallParticipants().filter((person) => (
person.hand_raised
&& person.role === 'attendee'
&& !person.speak_requested
));
},
spaceSpeakInvitePendingParticipants() {
return this.inCallParticipants().filter((person) => (
person.role === 'attendee'
&& person.speak_requested
));
},
spaceSpeakerUrl(template, participantUuid) {
@@ -2194,10 +2209,19 @@ function meetRoom() {
if (participant.uuid === this.config.participantUuid) {
this.canPublish = ['host', 'co_host', 'panelist'].includes(participant.role);
this.speakRequested = Boolean(participant.speak_requested);
}
},
async promoteSpaceSpeaker(participantUuid) {
async refreshMediaAccess() {
if (!this.config.configured) {
return;
}
await this.reconnectLiveKit();
},
async inviteSpaceSpeaker(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spacePromoteUrl, participantUuid);
if (!this.isSpace || !this.isHost || !url) {
return;
@@ -2216,6 +2240,81 @@ function meetRoom() {
this.mergeSessionParticipant(data.participant);
},
async acceptSpaceSpeakInvitation() {
if (!this.isSpace || !this.config.spaceSpeakAcceptUrl) {
return;
}
const res = await fetch(this.config.spaceSpeakAcceptUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
await this.refreshMediaAccess();
},
async declineSpaceSpeakInvitation() {
if (!this.isSpace || !this.config.spaceSpeakDeclineUrl) {
return;
}
const res = await fetch(this.config.spaceSpeakDeclineUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
},
async dismissSpaceSpeakInvitation(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spaceSpeakDismissUrl, 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 promoteSpaceCoHost(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spaceCoHostUrl, 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) {
+44 -4
View File
@@ -84,6 +84,10 @@
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-space-speak-accept-url="{{ ($isSpace ?? false) ? route('meet.room.space.speak.accept', $session) : '' }}"
data-space-speak-decline-url="{{ ($isSpace ?? false) ? route('meet.room.space.speak.decline', $session) : '' }}"
data-space-speak-dismiss-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.speak.dismiss', [$session, '__UUID__']) : '' }}"
data-space-co-host-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.co-host', [$session, '__UUID__']) : '' }}"
data-uses-speak-access="{{ ($usesSpeakAccess ?? false) ? '1' : '0' }}"
data-can-manage-speak="{{ ($canManageSpeakAccess ?? false) ? '1' : '0' }}"
data-leave-url="{{ route('meet.room.leave', $session) }}"
@@ -368,6 +372,16 @@
You can speak your microphone is available
</p>
</div>
<div x-show="isSpace && speakRequested && !canPublish" x-cloak
class="absolute inset-x-0 -top-14 flex justify-center px-4 sm:-top-16">
<div class="flex flex-wrap items-center justify-center gap-2 rounded-2xl bg-indigo-600/90 px-4 py-2.5 text-sm text-white shadow-lg ring-1 ring-indigo-400/40">
<span class="font-medium">You've been invited to speak</span>
<button type="button" @click="acceptSpaceSpeakInvitation()"
class="rounded-lg bg-white px-3 py-1 text-xs font-semibold text-indigo-700 hover:bg-indigo-50">Accept</button>
<button type="button" @click="declineSpaceSpeakInvitation()"
class="rounded-lg bg-indigo-800/80 px-3 py-1 text-xs font-medium text-indigo-100 hover:bg-indigo-800">Decline</button>
</div>
</div>
<div class="meet-toolbar mx-auto max-w-5xl">
<div class="meet-toolbar__track sm:px-0">
<div class="meet-toolbar__primary sm:contents">
@@ -434,7 +448,7 @@
@if ($participant->isHost())
<button @click="toggleRecording()" type="button"
x-show="isSpace"
class="rounded-full p-3 transition-colors"
class="rounded-full p-3 transition-colors sm:hidden"
: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>
@@ -651,12 +665,33 @@
<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)"
<button type="button" @click="inviteSpaceSpeaker(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="isSpace && isHost && spaceSpeakInvitePendingParticipants().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-sky-300">
Invited to speak
<span class="ml-1 rounded-full bg-sky-500/20 px-1.5 py-0.5 text-[10px] text-sky-200"
x-text="spaceSpeakInvitePendingParticipants().length"></span>
</p>
<template x-for="person in spaceSpeakInvitePendingParticipants()" :key="'space-invite-'+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-sky-500/20 text-sm font-semibold text-sky-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-sky-200/80">Waiting for response</span>
</span>
<button type="button" @click="dismissSpaceSpeakInvitation(person.uuid)"
class="rounded-lg bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-200 hover:bg-slate-600">Cancel invite</button>
</div>
</template>
</div>
</template>
<template x-if="isHost && waitingParticipants().length">
<div class="mb-3 border-b border-slate-800 pb-3">
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-amber-300">
@@ -738,12 +773,17 @@
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
Make speaker
</button>
<button x-show="isSpace && isHost && person.role === 'attendee'"
type="button" @click="promoteSpaceSpeaker(person.uuid)"
<button x-show="isSpace && isHost && person.role === 'attendee' && !person.speak_requested"
type="button" @click="inviteSpaceSpeaker(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="promoteSpaceCoHost(person.uuid)"
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
Make co-host
</button>
<button x-show="isSpace && isHost && (person.role === 'panelist' || person.role === 'co_host')"
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
+5 -1
View File
@@ -71,8 +71,12 @@ 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/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}/space/speaker', [MeetingRoomController::class, 'promoteSpaceSpeaker'])->name('meet.room.space.promote');
Route::post('/room/{session}/participants/{participant}/space/speaker', [MeetingRoomController::class, 'inviteSpaceSpeaker'])->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}/space/speaker/accept', [MeetingRoomController::class, 'acceptSpaceSpeakInvitation'])->name('meet.room.space.speak.accept');
Route::post('/room/{session}/space/speaker/decline', [MeetingRoomController::class, 'declineSpaceSpeakInvitation'])->name('meet.room.space.speak.decline');
Route::post('/room/{session}/participants/{participant}/space/speaker/dismiss', [MeetingRoomController::class, 'dismissSpaceSpeakInvitation'])->name('meet.room.space.speak.dismiss');
Route::post('/room/{session}/participants/{participant}/space/co-host', [MeetingRoomController::class, 'promoteSpaceCoHost'])->name('meet.room.space.co-host');
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::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll');
+71 -3
View File
@@ -1306,7 +1306,7 @@ class MeetWebTest extends TestCase
$response->assertDontSee('View the room lineup from Events', false);
}
public function test_space_host_can_promote_listener_to_speaker(): void
public function test_space_host_can_invite_listener_to_speak_and_listener_accepts(): void
{
$room = Room::create([
'owner_ref' => $this->user->public_id,
@@ -1357,16 +1357,84 @@ class MeetWebTest extends TestCase
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
->postJson('/room/'.$session->uuid.'/participants/'.$listener->uuid.'/space/speaker')
->assertOk()
->assertJsonPath('participant.role', 'panelist');
->assertJsonPath('participant.role', 'attendee')
->assertJsonPath('participant.speak_requested', true);
$listener->refresh();
$this->assertSame('attendee', $listener->role);
$this->assertTrue($listener->speak_requested);
$this->assertFalse($listener->hand_raised);
$this->withSession(["meet.participant.{$session->uuid}" => $listener->uuid])
->postJson('/room/'.$session->uuid.'/space/speaker/accept')
->assertOk()
->assertJsonPath('participant.role', 'panelist')
->assertJsonPath('participant.speak_requested', false);
$listener->refresh();
$room->refresh();
$this->assertSame('panelist', $listener->role);
$this->assertFalse($listener->hand_raised);
$this->assertFalse($listener->speak_requested);
$this->assertContains('listener-ref', (array) $room->setting('speaker_refs', []));
}
public function test_space_host_can_make_speaker_co_host_without_acceptance(): 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' => ['speaker-ref'],
]),
]);
$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(),
]);
$speaker = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'user_ref' => 'speaker-ref',
'display_name' => 'Speaker',
'role' => 'panelist',
'status' => 'joined',
'joined_at' => now(),
]);
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
->postJson('/room/'.$session->uuid.'/participants/'.$speaker->uuid.'/space/co-host')
->assertOk()
->assertJsonPath('participant.role', 'co_host');
$speaker->refresh();
$this->assertSame('co_host', $speaker->role);
}
public function test_breakouts_exclude_host_and_enable_meeting_mode_for_attendees(): void
{
config([