Add live speaker invites for audio rooms and simplify the toolbar.
Deploy Ladill Meet / deploy (push) Successful in 1m9s
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:
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
Reference in New Issue
Block a user