Require acceptance for space speak invites and add instant co-host promotion.
Deploy Ladill Meet / deploy (push) Successful in 40s
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:
@@ -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([
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user