Add speak access for webinar/conference attendees and fix Events linking UX.
Deploy Ladill Meet / deploy (push) Successful in 33s

Let attendees request host-granted microphone access with LiveKit reconnect,
participant panel controls, and toolbar UI that only shows mic when allowed.
Fix Events create URL, surface API key setup guidance, and align the Webinars
sidebar icon with other nav items using currentColor strokes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-02 07:59:18 +00:00
co-authored by Cursor
parent 9ab7d3e685
commit d648f6c862
22 changed files with 674 additions and 44 deletions
+1
View File
@@ -63,6 +63,7 @@ MEET_API_KEY_SUPPORT=
MEET_API_KEY_INVOICE=
# --- Ladill Events (registration for webinars & conferences) ---
# Same secret on both apps: MEET_API_KEY_EVENTS here, EVENTS_API_KEY_MEET in ladill-events.
LADILL_EVENTS_APP_URL=https://events.ladill.com
LADILL_EVENTS_API_URL=https://events.ladill.com/api/service/v1
@@ -30,7 +30,7 @@ class EventsLinkController extends Controller
if (! $this->eventsClient->isConfigured()) {
return redirect()
->route($room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show', $room)
->withErrors(['events' => 'Ladill Events integration is not configured on this Meet instance.']);
->withErrors(['events' => 'Ladill Events linking is not configured. Set MEET_API_KEY_EVENTS in Meet (and the matching EVENTS_API_KEY_MEET in Events), then try again.']);
}
$events = $this->links->linkableEvents($request->user());
@@ -77,6 +77,9 @@ class MeetingRoomController extends Controller
$authUser = $request->user();
$isRoomHost = $authUser && $authUser->ownerRef() === $room->host_user_ref;
$canManageConference = $participant->isHost() || $isRoomHost;
$canManageSpeakAccess = app(\App\Services\Meet\SpeakAccessService::class)->canManageSpeakAccess($participant);
$usesSpeakAccess = $room->isWebinar() || $room->isConference();
$mediaState = $this->sessions->mediaSessionState($participant);
$breakoutService = app(BreakoutService::class);
$inBreakout = $breakoutService->isInActiveBreakout($participant);
@@ -97,7 +100,12 @@ class MeetingRoomController extends Controller
'presenterRefs' => (array) $session->presenter_refs,
'isAudioOnly' => $isAudioOnly,
'isSpace' => $room->isSpace(),
'canPublish' => $this->sessions->canParticipantPublish($participant),
'canPublish' => $mediaState['can_publish'],
'audioOnlyPublish' => $mediaState['audio_only_publish'],
'speakGranted' => $mediaState['speak_granted'],
'speakRequested' => $mediaState['speak_requested'],
'usesSpeakAccess' => $usesSpeakAccess,
'canManageSpeakAccess' => $canManageSpeakAccess,
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
'usesStageLayout' => $stageLayout->usesStageLayout($room) && ! $inBreakout,
'inBreakout' => $inBreakout,
@@ -186,6 +194,62 @@ class MeetingRoomController extends Controller
return response()->json(['hand_raised' => $participant->hand_raised]);
}
public function requestSpeak(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->requestSpeak($participant),
]);
}
public function cancelSpeakRequest(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->cancelSpeakRequest($participant),
]);
}
public function grantSpeak(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->grantSpeak($participant, $actor),
]);
}
public function revokeSpeak(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->revokeSpeak($participant, $actor),
]);
}
public function dismissSpeakRequest(Request $request, Session $session, Participant $participant): JsonResponse
{
$actor = $this->currentParticipant($request, $session);
abort_unless($participant->session_id === $session->id, 404);
$speak = app(\App\Services\Meet\SpeakAccessService::class);
return response()->json([
'participant' => $speak->dismissSpeakRequest($participant, $actor),
]);
}
public function sendMessage(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
+12 -1
View File
@@ -16,7 +16,7 @@ class Participant extends Model
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'user_ref', 'display_name', 'email',
'role', 'status', 'joined_at', 'left_at', 'device_info', 'ip_address',
'hand_raised', 'is_muted', 'is_video_off', 'breakout_room_id',
'hand_raised', 'speak_requested', 'speak_granted', 'is_muted', 'is_video_off', 'breakout_room_id',
];
protected function casts(): array
@@ -25,6 +25,8 @@ class Participant extends Model
'joined_at' => 'datetime',
'left_at' => 'datetime',
'hand_raised' => 'boolean',
'speak_requested' => 'boolean',
'speak_granted' => 'boolean',
'is_muted' => 'boolean',
'is_video_off' => 'boolean',
];
@@ -66,9 +68,18 @@ class Participant extends Model
public function canPublishMedia(): bool
{
if ($this->speak_granted) {
return true;
}
return $this->role !== 'attendee';
}
public function hasSpeakAccess(): bool
{
return $this->speak_granted;
}
public function breakoutRoom(): BelongsTo
{
return $this->belongsTo(BreakoutRoom::class, 'breakout_room_id');
+8
View File
@@ -87,6 +87,14 @@ class ConferenceService
public function canPublish(Room $room, Participant $participant): bool
{
if (in_array($participant->role, ['host', 'co_host', 'panelist'], true)) {
return true;
}
if ($room->isTownHall() || $room->isWebinar()) {
return (bool) $participant->speak_granted;
}
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
}
@@ -41,6 +41,8 @@ class ParticipantPresenter
'role' => $participant->role,
'status' => $participant->status,
'hand_raised' => $participant->hand_raised,
'speak_requested' => (bool) $participant->speak_requested,
'speak_granted' => (bool) $participant->speak_granted,
'is_muted' => $participant->is_muted,
'is_video_off' => $participant->is_video_off,
'breakout_room_id' => $participant->breakout_room_id,
+10 -2
View File
@@ -342,7 +342,7 @@ class SessionService
);
}
/** @return array{can_publish: bool, uses_stage_layout: bool, in_breakout: bool, breakout_room_id: ?int, breakout: ?array{uuid: string, name: string, closes_at: ?string}} */
/** @return array{can_publish: bool, audio_only_publish: bool, speak_granted: bool, speak_requested: bool, uses_stage_layout: bool, in_breakout: bool, breakout_room_id: ?int, breakout: ?array{uuid: string, name: string, closes_at: ?string}} */
public function mediaSessionState(Participant $participant): array
{
$participant->loadMissing(['breakoutRoom', 'session.room']);
@@ -350,6 +350,11 @@ class SessionService
$breakoutService = app(BreakoutService::class);
$inBreakout = $breakoutService->isInActiveBreakout($participant);
$usesStageLayout = app(StageLayoutService::class)->usesStageLayout($room) && ! $inBreakout;
$canPublish = $this->canParticipantPublish($participant, $inBreakout);
$audioOnlyPublish = $canPublish
&& $participant->isAttendee()
&& (bool) $participant->speak_granted
&& ! $inBreakout;
$breakout = null;
if ($inBreakout && $participant->breakoutRoom) {
@@ -361,7 +366,10 @@ class SessionService
}
return [
'can_publish' => $this->canParticipantPublish($participant, $inBreakout),
'can_publish' => $canPublish,
'audio_only_publish' => $audioOnlyPublish,
'speak_granted' => (bool) $participant->speak_granted,
'speak_requested' => (bool) $participant->speak_requested,
'uses_stage_layout' => $usesStageLayout,
'in_breakout' => $inBreakout,
'breakout_room_id' => $inBreakout ? $participant->breakout_room_id : null,
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services\Meet;
use App\Models\Participant;
use App\Models\Room;
class SpeakAccessService
{
public function usesSpeakAccess(Room $room): bool
{
return $room->isWebinar() || $room->isConference();
}
public function canManageSpeakAccess(Participant $participant): bool
{
$room = $participant->session->room;
if (! $this->usesSpeakAccess($room)) {
return false;
}
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
}
public function requestSpeak(Participant $participant): Participant
{
$room = $participant->session->room;
abort_unless($this->usesSpeakAccess($room), 422);
abort_unless($participant->role === 'attendee', 422);
abort_unless(! $participant->speak_granted, 422, 'You already have speak access.');
$participant->update(['speak_requested' => true]);
return $participant->fresh();
}
public function cancelSpeakRequest(Participant $participant): Participant
{
$participant->update(['speak_requested' => false]);
return $participant->fresh();
}
public function grantSpeak(Participant $target, Participant $actor): Participant
{
abort_unless($this->canManageSpeakAccess($actor), 403);
abort_unless($target->session_id === $actor->session_id, 404);
abort_unless($target->status === 'joined', 422);
abort_unless($this->usesSpeakAccess($target->session->room), 422);
$target->update([
'speak_granted' => true,
'speak_requested' => false,
]);
return $target->fresh();
}
public function revokeSpeak(Participant $target, Participant $actor): Participant
{
abort_unless($this->canManageSpeakAccess($actor), 403);
abort_unless($target->session_id === $actor->session_id, 404);
$target->update([
'speak_granted' => false,
'speak_requested' => false,
'is_muted' => true,
]);
return $target->fresh();
}
public function dismissSpeakRequest(Participant $target, Participant $actor): Participant
{
abort_unless($this->canManageSpeakAccess($actor), 403);
abort_unless($target->session_id === $actor->session_id, 404);
$target->update(['speak_requested' => false]);
return $target->fresh();
}
}
+5 -1
View File
@@ -67,6 +67,10 @@ class WebinarService
return true;
}
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
if (in_array($participant->role, ['host', 'co_host', 'panelist'], true)) {
return true;
}
return (bool) $participant->speak_granted;
}
}
+10
View File
@@ -27,6 +27,16 @@ class EventsSourceLink
return rtrim((string) config('meet.events_app_url', 'https://events.ladill.com'), '/');
}
public static function isApiConfigured(): bool
{
return filled(config('meet.events_api_url')) && filled(config('meet.events_api_key'));
}
public static function createEventUrl(?Room $room = null): string
{
return self::baseUrl().'/events/create';
}
public static function eventAdminUrl(Room $room): ?string
{
$eventId = self::eventId($room);
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('meet_participants', function (Blueprint $table) {
$table->boolean('speak_requested')->default(false)->after('hand_raised');
$table->boolean('speak_granted')->default(false)->after('speak_requested');
});
}
public function down(): void
{
Schema::table('meet_participants', function (Blueprint $table) {
$table->dropColumn(['speak_requested', 'speak_granted']);
});
}
};
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="M24,17.5c0,2.345-.913,4.55-2.571,6.207-.195,.195-.451,.293-.707,.293s-.512-.098-.707-.293c-.391-.391-.391-1.023,0-1.414,1.28-1.28,1.985-2.982,1.985-4.793s-.705-3.513-1.985-4.793c-.391-.391-.391-1.023,0-1.414s1.023-.391,1.414,0c1.658,1.657,2.571,3.862,2.571,6.207Zm-5.413-3.407c-.391-.391-1.023-.391-1.414,0s-.391,1.023,0,1.414c1.099,1.099,1.099,2.888,0,3.986-.391,.391-.391,1.023,0,1.414,.195,.195,.451,.293,.707,.293s.512-.098,.707-.293c1.879-1.879,1.879-4.936,0-6.814ZM1.452,.106c-.219,.029-.435,.066-.649,.109C.262,.325-.089,.852,.02,1.394c.109,.541,.631,.893,1.178,.783,.174-.035,.351-.064,.527-.089,3.314-.459,6.479,.865,8.498,3.56,1.319,1.631,3.695,6.546,3.777,7.353h-1.207c-.501,0-.926,.372-.991,.869l-.334,2.524c-.196,1.486-1.475,2.606-2.974,2.606h-1.494c-.552,0-1,.447-1,1v1c0,.552-.449,1-1,1H1c-.552,0-1,.447-1,1s.448,1,1,1H5c1.654,0,3-1.346,3-3h.494c2.499,0,4.63-1.868,4.957-4.345l.219-1.655h.33c1.299,0,2-1.03,2-2,0-1.42-2.932-7.015-4.2-8.581C9.363,1.164,5.497-.449,1.452,.106Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+195 -4
View File
@@ -53,6 +53,11 @@ function meetRoom() {
goingLive: false,
presenterRefs: [],
audioOnly: false,
audioOnlyPublish: false,
usesSpeakAccess: false,
canManageSpeak: false,
speakGranted: false,
speakRequested: false,
isSpace: false,
usesStageLayout: false,
panelDiscussions: false,
@@ -112,6 +117,11 @@ function meetRoom() {
chatUrl: el.dataset.chatUrl,
reactionUrl: el.dataset.reactionUrl,
raiseHandUrl: el.dataset.raiseHandUrl,
speakRequestUrl: el.dataset.speakRequestUrl,
speakCancelUrl: el.dataset.speakCancelUrl,
speakGrantUrl: el.dataset.speakGrantUrl,
speakRevokeUrl: el.dataset.speakRevokeUrl,
speakDismissUrl: el.dataset.speakDismissUrl,
recordingStartUrl: el.dataset.recordingStartUrl,
recordingStopUrl: el.dataset.recordingStopUrl,
lockUrl: el.dataset.lockUrl,
@@ -143,6 +153,11 @@ function meetRoom() {
this.isHost = el.dataset.isHost === '1';
this.config.participantUuid = el.dataset.participant || '';
this.canPublish = el.dataset.canPublish !== '0';
this.audioOnlyPublish = el.dataset.audioOnlyPublish === '1';
this.usesSpeakAccess = el.dataset.usesSpeakAccess === '1';
this.canManageSpeak = el.dataset.canManageSpeak === '1';
this.speakGranted = el.dataset.speakGranted === '1';
this.speakRequested = el.dataset.speakRequested === '1';
this.inBreakout = el.dataset.inBreakout === '1';
this.isWebinar = el.dataset.isWebinar === '1';
this.isConference = el.dataset.isConference === '1';
@@ -384,7 +399,7 @@ function meetRoom() {
async enableLocalMedia(activeRoom, attempts = 3) {
const wantMic = this.inBreakout ? !this.muteOnJoin : (!this.muteOnJoin || this.isHost);
const wantCamera = !this.videoOffOnJoin && !this.audioOnly;
const wantCamera = !this.videoOffOnJoin && !this.audioOnly && !this.audioOnlyPublish;
const audioOptions = {
echoCancellation: true,
noiseSuppression: true,
@@ -1583,6 +1598,9 @@ function meetRoom() {
this.inBreakout = Boolean(media.in_breakout);
this.assignedBreakoutRoomId = media.breakout_room_id ?? null;
this.canPublish = Boolean(media.can_publish);
this.audioOnlyPublish = Boolean(media.audio_only_publish);
this.speakGranted = Boolean(media.speak_granted);
this.speakRequested = Boolean(media.speak_requested);
this.usesStageLayout = Boolean(media.uses_stage_layout);
this.breakoutRoomName = media.breakout?.name || '';
this.breakoutClosesAt = media.breakout?.closes_at || '';
@@ -1596,6 +1614,8 @@ function meetRoom() {
const previousBreakoutId = this.assignedBreakoutRoomId;
const previousCanPublish = this.canPublish;
const previousAudioOnlyPublish = this.audioOnlyPublish;
const previousSpeakGranted = this.speakGranted;
const previousUsesStageLayout = this.usesStageLayout;
this.applyMediaSessionState(media);
@@ -1604,6 +1624,8 @@ function meetRoom() {
const needsReconnect = previousBreakoutId !== this.assignedBreakoutRoomId
|| previousCanPublish !== this.canPublish
|| previousAudioOnlyPublish !== this.audioOnlyPublish
|| previousSpeakGranted !== this.speakGranted
|| previousUsesStageLayout !== this.usesStageLayout;
if (needsReconnect && this.config.configured) {
@@ -1828,7 +1850,7 @@ function meetRoom() {
},
async toggleMute() {
if (!room || !this.canPublish) return;
if (!room || !this.showMicrophoneControl()) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isMicrophoneEnabled;
@@ -1855,7 +1877,7 @@ function meetRoom() {
},
async toggleVideo() {
if (!room || this.audioOnly || !this.canPublish) return;
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isCameraEnabled;
await room.localParticipant.setCameraEnabled(!enabled);
@@ -1874,7 +1896,7 @@ function meetRoom() {
},
async toggleScreenShare() {
if (!room || this.audioOnly || !this.canPublish) return;
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
this.isScreenSharing = !this.isScreenSharing;
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
if (!this.isScreenSharing) {
@@ -1893,6 +1915,167 @@ function meetRoom() {
});
},
showMicrophoneControl() {
if (!this.canPublish) {
return false;
}
if (this.audioOnlyPublish) {
return this.speakGranted;
}
return true;
},
showVideoControl() {
return this.canPublish && !this.audioOnlyPublish;
},
showAudienceControls() {
if (!this.usesSpeakAccess) {
return this.canPublish;
}
return !this.canPublish || this.audioOnlyPublish;
},
showStagePublisherControls() {
return this.usesSpeakAccess && this.canPublish && !this.audioOnlyPublish;
},
speakRequestParticipants() {
return this.inCallParticipants().filter((person) => person.speak_requested && !person.speak_granted);
},
syncSelfSpeakState() {
const self = this.sessionParticipants.find((person) => person.uuid === this.config.participantUuid);
if (!self) {
return;
}
this.speakGranted = Boolean(self.speak_granted);
this.speakRequested = Boolean(self.speak_requested);
},
mergeSpeakParticipant(participant) {
if (!participant?.uuid) {
return;
}
const index = this.sessionParticipants.findIndex((person) => person.uuid === participant.uuid);
if (index >= 0) {
this.sessionParticipants[index] = {
...this.sessionParticipants[index],
speak_requested: Boolean(participant.speak_requested),
speak_granted: Boolean(participant.speak_granted),
};
}
if (participant.uuid === this.config.participantUuid) {
this.speakGranted = Boolean(participant.speak_granted);
this.speakRequested = Boolean(participant.speak_requested);
}
},
speakActionUrl(template, participantUuid) {
return template?.replace('__UUID__', participantUuid) || '';
},
async requestSpeak() {
if (!this.config.speakRequestUrl) {
return;
}
const res = await fetch(this.config.speakRequestUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
},
async cancelSpeakRequest() {
if (!this.config.speakCancelUrl) {
return;
}
const res = await fetch(this.config.speakCancelUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
},
async grantSpeakAccess(participantUuid) {
const url = this.speakActionUrl(this.config.speakGrantUrl, participantUuid);
if (!url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
await this.poll();
},
async revokeSpeakAccess(participantUuid) {
const url = this.speakActionUrl(this.config.speakRevokeUrl, participantUuid);
if (!url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
await this.poll();
},
async dismissSpeakRequest(participantUuid) {
const url = this.speakActionUrl(this.config.speakDismissUrl, participantUuid);
if (!url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
},
async goLive() {
if (!this.config.goLiveUrl || this.goingLive) {
return;
@@ -2059,10 +2242,18 @@ function meetRoom() {
return;
}
if (data.participants) {
const previousSpeakRequestCount = this.speakRequestParticipants().length;
this.sessionParticipants = data.participants;
if (this.usesSpeakAccess) {
this.syncSelfSpeakState();
}
this.pruneTilesFromSession();
this.refreshParticipantTileAvatars();
this.renderAudienceStrip();
const speakRequestCount = this.speakRequestParticipants().length;
if (this.canManageSpeak && speakRequestCount > previousSpeakRequestCount) {
this.openParticipantsPanel();
}
}
if (typeof data.uses_stage_layout === 'boolean' || typeof data.stage_layout === 'string') {
this.mergeStageConfig(data);
@@ -6,12 +6,6 @@
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
</div>
@if ($errors->any())
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $errors->first() }}
</div>
@endif
@if (session('success'))
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{{ session('success') }}
+2 -1
View File
@@ -1,3 +1,4 @@
@php use App\Support\EventsSourceLink; @endphp
<x-app-layout :title="'Link to Ladill Events · '.$room->title">
<div class="mx-auto max-w-xl">
<a href="{{ $room->isWebinar() ? route('meet.webinars.show', $room) : route('meet.conferences.show', $room) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Back</a>
@@ -15,7 +16,7 @@
@if ($events === [])
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
<p>No virtual or hybrid events found in your Ladill Events account.</p>
<a href="{{ $eventsAppUrl }}/qr-codes/create" target="_blank" rel="noopener" class="mt-4 inline-flex btn-primary btn-primary-sm">Create event in Events</a>
<a href="{{ EventsSourceLink::createEventUrl($room) }}" target="_blank" rel="noopener" class="mt-4 inline-flex btn-primary btn-primary-sm">Create event in Events</a>
</div>
@else
<form method="POST" action="{{ route('meet.events.link.store', $room) }}" class="mt-6 space-y-5">
@@ -4,7 +4,8 @@
$eventsUrls = EventsSourceLink::urls($room);
$isLinked = EventsSourceLink::isLinked($room);
$eventTitle = EventsSourceLink::eventTitle($room);
$eventsBase = EventsSourceLink::baseUrl();
$eventsConfigured = EventsSourceLink::isApiConfigured();
$createEventUrl = EventsSourceLink::createEventUrl($room);
$kind = $room->isConference() ? 'conference' : 'webinar';
@endphp
@@ -32,6 +33,17 @@
@endif
</div>
@if (! $isLinked && ! $eventsConfigured)
<div class="mt-4 rounded-lg border border-amber-300/80 bg-amber-100/60 px-4 py-3 text-sm text-amber-950">
<p class="font-medium">Events linking is not configured on this Meet instance.</p>
<p class="mt-1 text-xs leading-relaxed text-amber-900/90">
Set <span class="font-mono">MEET_API_KEY_EVENTS</span> in Meet and the matching
<span class="font-mono">EVENTS_API_KEY_MEET</span> in Events so Meet can list and link your events.
You can still create an event in Events using the button below.
</p>
</div>
@endif
@if ($isLinked)
<div class="mt-4 grid gap-2 sm:grid-cols-2">
@if ($eventsUrls['registration'])
@@ -72,8 +84,12 @@
<p class="mt-3 text-xs text-slate-500">Programme speakers and panelists are configured on the event programme in Events and sync to this session when linked.</p>
@else
<div class="mt-4 flex flex-wrap gap-3">
<a href="{{ route('meet.events.link', $room) }}" class="btn-primary btn-primary-sm">Link to Ladill Events</a>
<a href="{{ $eventsBase }}/qr-codes/create" target="_blank" rel="noopener" class="btn-secondary btn-secondary-sm">Create event in Events</a>
@if ($eventsConfigured)
<a href="{{ route('meet.events.link', $room) }}" class="btn-primary btn-primary-sm">Link to Ladill Events</a>
@else
<span class="btn-primary btn-primary-sm cursor-not-allowed opacity-50" title="Configure MEET_API_KEY_EVENTS to enable linking">Link to Ladill Events</span>
@endif
<a href="{{ $createEventUrl }}" target="_blank" rel="noopener" class="btn-secondary btn-secondary-sm">Create event in Events</a>
</div>
@endif
</div>
@@ -18,6 +18,7 @@
'start-breakout',
'upload-file',
'programme',
'speak',
];
$iconName = in_array($icon, $allowed, true) ? $icon : null;
$iconPath = $iconName ? public_path('images/meet-icons/'.$iconName.'.svg') : null;
+99 -11
View File
@@ -68,6 +68,13 @@
data-chat-url="{{ route('meet.room.chat', $session) }}"
data-reaction-url="{{ route('meet.room.reaction', $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-cancel-url="{{ ($usesSpeakAccess ?? false) ? route('meet.room.speak.cancel', $session) : '' }}"
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-dismiss-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.dismiss', [$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) }}"
data-end-url="{{ route('meet.room.end', $session) }}"
data-recording-start-url="{{ route('meet.room.recording.start', $session) }}"
@@ -99,6 +106,9 @@
data-spotlight-speaker-ref="{{ $stageConfig['spotlight_speaker_ref'] ?? '' }}"
data-presenter-refs='@json($presenterRefs ?? [])'
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
data-audio-only-publish="{{ ($audioOnlyPublish ?? false) ? '1' : '0' }}"
data-speak-granted="{{ ($speakGranted ?? false) ? '1' : '0' }}"
data-speak-requested="{{ ($speakRequested ?? false) ? '1' : '0' }}"
data-in-breakout="{{ ($inBreakout ?? false) ? '1' : '0' }}"
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
@@ -318,11 +328,17 @@
</main>
<footer class="relative shrink-0 bg-slate-950 py-4 sm:px-4">
<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">
<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">
You can speak your microphone is available
</p>
</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">
<button @click="toggleMute()" type="button"
x-show="canPublish"
x-show="showMicrophoneControl()"
class="rounded-full p-3 transition-colors"
:class="isMuted ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
:title="isMuted ? 'Unmute' : 'Mute'">
@@ -330,7 +346,7 @@
<svg x-show="isMuted" x-cloak 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="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</button>
<button @click="toggleVideo()" type="button"
x-show="canPublish && !audioOnly"
x-show="showVideoControl()"
class="rounded-full p-3 transition-colors"
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
:title="isVideoOff ? 'Turn camera on' : 'Turn camera off'">
@@ -338,26 +354,26 @@
<svg x-show="isVideoOff" x-cloak 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.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</button>
<button @click="toggleScreenShare()" type="button"
x-show="canPublish && !audioOnly"
x-show="showVideoControl()"
class="rounded-full p-3 transition-colors"
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="Share screen">
@include('meet.room.partials.meet-icon', ['icon' => 'screen-share'])
</button>
<button @click="raiseHand()" type="button"
x-show="!canPublish"
x-show="showAudienceControls()"
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
title="Raise hand">
@include('meet.room.partials.meet-icon', ['icon' => 'raise-hand'])
</button>
<button @click="sendReaction('thumbs')" type="button"
x-show="!canPublish"
x-show="showAudienceControls()"
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
title="Applause — sound plays when others join in">
@include('meet.room.partials.meet-icon', ['icon' => 'applause'])
</button>
<button @click="sendReaction('heart')" type="button"
x-show="!canPublish"
x-show="showAudienceControls()"
class="rounded-full bg-slate-700 p-3 hover:bg-slate-600"
title="React">
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
@@ -370,13 +386,13 @@
</button>
</div>
<button @click="raiseHand()" type="button" x-show="canPublish" 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()" 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'])
</button>
<button @click="sendReaction('thumbs')" type="button" x-show="canPublish" 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()" 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'])
</button>
<button @click="sendReaction('heart')" type="button" x-show="canPublish" 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()" 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'])
</button>
<button @click="toggleChat()" type="button"
@@ -564,6 +580,31 @@
</template>
</div>
</template>
<template x-if="canManageSpeak && speakRequestParticipants().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">
Requesting 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="speakRequestParticipants().length"></span>
</p>
<template x-for="person in speakRequestParticipants()" :key="'speak-req-'+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">Wants to speak</span>
</span>
<span class="flex shrink-0 gap-1">
<button type="button" @click="grantSpeakAccess(person.uuid)"
class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-emerald-500">Allow</button>
<button type="button" @click="dismissSpeakRequest(person.uuid)"
class="rounded-lg bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-200 hover:bg-slate-600">Dismiss</button>
</span>
</div>
</template>
</div>
</template>
<template x-for="person in (usesStageLayout ? speakerParticipantsSorted() : inCallParticipantsSorted())" :key="person.uuid">
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5"
:class="isSpeaking(person.uuid) ? 'bg-emerald-500/10 ring-1 ring-emerald-500/40' : ''">
@@ -595,8 +636,22 @@
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="canManageSpeak && usesSpeakAccess && person.role === 'attendee' && !person.speak_granted"
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">
Invite to speak
</button>
<button x-show="canManageSpeak && usesSpeakAccess && person.speak_granted"
type="button" @click="revokeSpeakAccess(person.uuid)"
class="rounded-lg bg-slate-600 px-2 py-1 text-[10px] font-medium text-slate-100 hover:bg-slate-500">
Revoke speak
</button>
</span>
<span class="flex shrink-0 items-center gap-1 text-slate-400">
<span x-show="person.speak_granted" class="rounded-full bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-medium text-emerald-300" title="Can speak">Mic</span>
<span x-show="person.speak_requested && !person.speak_granted" class="rounded-full bg-sky-500/20 p-1 text-sky-300" title="Requested to speak">
@include('meet.room.partials.meet-icon', ['icon' => 'speak', 'class' => 'h-3.5 w-3.5'])
</span>
<span x-show="isSpeaking(person.uuid)" class="meet-speaking-badge rounded-full bg-emerald-500/20 p-1 text-emerald-300" title="Speaking">
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect class="meet-speaking-bar meet-speaking-bar--1" x="1" y="5" width="2.5" height="6" rx="1"/>
@@ -709,7 +764,7 @@
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div class="space-y-1">
<div class="space-y-1 sm:hidden" x-show="canPublish">
<div class="space-y-1 sm:hidden" x-show="showAudienceControls()">
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Let the host know you have a question"
@click="raiseHand(); closeSidebar()" />
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send applause — sound plays when others join in"
@@ -720,7 +775,40 @@
@click="toggleChat()" />
<div class="my-1 border-t border-slate-800"></div>
</div>
<div class="space-y-1 sm:hidden" x-show="!canPublish">
<div class="space-y-1" x-show="usesSpeakAccess && !speakGranted && !speakRequested && showAudienceControls()">
<x-meet.room.menu-item icon="speak" title="Request to speak" subtitle="Ask the host to enable your microphone"
@click="requestSpeak(); closeSidebar()" />
<div class="my-1 border-t border-slate-800"></div>
</div>
<div class="space-y-1" x-show="usesSpeakAccess && speakRequested && !speakGranted">
<button type="button" disabled
class="flex w-full cursor-default items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-400">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500/15">
@include('meet.room.partials.meet-icon', ['icon' => 'speak', 'class' => 'h-5 w-5'])
</span>
<span class="min-w-0">
<span class="block font-medium text-sky-300">Speak request sent</span>
<span class="block text-xs text-slate-500">Waiting for the host to allow your microphone</span>
</span>
</button>
<button type="button" @click="cancelSpeakRequest(); closeSidebar()"
class="ml-12 text-xs font-medium text-slate-400 hover:text-slate-200">Cancel request</button>
<div class="my-1 border-t border-slate-800"></div>
</div>
<div class="space-y-1" x-show="usesSpeakAccess && speakGranted && audioOnlyPublish">
<button type="button" disabled
class="flex w-full cursor-default items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-400">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-emerald-500/15">
@include('meet.room.partials.meet-icon', ['icon' => 'speak', 'class' => 'h-5 w-5'])
</span>
<span class="min-w-0">
<span class="block font-medium text-emerald-300">You can speak</span>
<span class="block text-xs text-slate-500">Your microphone is available use the mic control below</span>
</span>
</button>
<div class="my-1 border-t border-slate-800"></div>
</div>
<div class="space-y-1 sm:hidden" x-show="showStagePublisherControls()">
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
@click="toggleChat()" />
<div class="my-1 border-t border-slate-800"></div>
@@ -14,12 +14,6 @@
@endif
</div>
@if ($errors->any())
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $errors->first() }}
</div>
@endif
@if (session('success'))
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{{ session('success') }}
+3 -7
View File
@@ -20,7 +20,8 @@
['name' => 'Conferences', 'route' => route('meet.conferences.index'), 'active' => request()->routeIs('meet.conferences.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />'],
['name' => 'Webinars', 'route' => route('meet.webinars.index'), 'active' => request()->routeIs('meet.webinars.*') || request()->routeIs('meet.webinar.*'),
'iconImg' => 'webinar'],
'iconViewBox' => '0 0 14 14',
'icon' => '<path d="M7 12.52C10.3137 12.52 13 9.83373 13 6.52002C13 3.20631 10.3137 0.52002 7 0.52002C3.68629 0.52002 1 3.20631 1 6.52002C1 9.83373 3.68629 12.52 7 12.52Z" /><path d="M10.07 11.6799C11.0132 12.1003 11.858 12.7135 12.55 13.4799" /><path d="M1.45 13.4799C2.14202 12.7135 2.9868 12.1003 3.93 11.6799" /><path d="M7 9.52002C8.65685 9.52002 10 8.17687 10 6.52002C10 4.86317 8.65685 3.52002 7 3.52002C5.34315 3.52002 4 4.86317 4 6.52002C4 8.17687 5.34315 9.52002 7 9.52002Z" /><path d="M7 7.02002C7.27614 7.02002 7.5 6.79616 7.5 6.52002C7.5 6.24388 7.27614 6.02002 7 6.02002C6.72386 6.02002 6.5 6.24388 6.5 6.52002C6.5 6.79616 6.72386 7.02002 7 7.02002Z" />'],
['name' => 'Recordings', 'route' => route('meet.recordings.index'), 'active' => request()->routeIs('meet.recordings.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />'],
['name' => 'Reports', 'route' => route('meet.reports.index'), 'active' => request()->routeIs('meet.reports.*'),
@@ -53,12 +54,7 @@
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@foreach ($nav as $item)
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
@if (! empty($item['iconImg']))
@php $navIconVer = @filemtime(public_path('images/meet-icons/'.$item['iconImg'].'.svg')) ?: '1'; @endphp
<img src="{{ asset('images/meet-icons/'.$item['iconImg'].'.svg') }}?v={{ $navIconVer }}" alt="" class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'opacity-100' : 'opacity-45 group-hover:opacity-70' }}">
@else
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">{!! $item['icon'] !!}</svg>
@endif
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" viewBox="{{ $item['iconViewBox'] ?? '0 0 24 24' }}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">{!! $item['icon'] !!}</svg>
<span>{{ $item['name'] }}</span>
</a>
@endforeach
+5
View File
@@ -65,6 +65,11 @@ Route::post('/room/{session}/leave', [MeetingRoomController::class, 'leave'])->n
Route::post('/room/{session}/participants/{participant}/admit', [MeetingRoomController::class, 'admit'])->name('meet.room.admit');
Route::post('/room/{session}/participants/{participant}/deny', [MeetingRoomController::class, 'deny'])->name('meet.room.deny');
Route::post('/room/{session}/raise-hand', [MeetingRoomController::class, 'raiseHand'])->name('meet.room.raise-hand');
Route::post('/room/{session}/speak/request', [MeetingRoomController::class, 'requestSpeak'])->name('meet.room.speak.request');
Route::post('/room/{session}/speak/cancel', [MeetingRoomController::class, 'cancelSpeakRequest'])->name('meet.room.speak.cancel');
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}/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');
+126
View File
@@ -1368,6 +1368,132 @@ class MeetWebTest extends TestCase
->assertSee('data-session-noun="webinar"', false);
}
public function test_webinar_attendee_can_request_and_receive_speak_access(): 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' => 'Town hall',
'type' => 'webinar',
'status' => 'live',
'scheduled_at' => now()->addHour(),
'timezone' => 'UTC',
'settings' => config('meet.default_settings'),
]);
$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(),
]);
$attendee = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'display_name' => 'Attendee',
'role' => 'attendee',
'status' => 'joined',
'joined_at' => now(),
]);
$sessions = app(\App\Services\Meet\SessionService::class);
$this->assertFalse($sessions->canParticipantPublish($attendee));
$this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid])
->get('/room/'.$session->uuid)
->assertOk()
->assertSee('Request to speak', false)
->assertSee('meet-icons/speak.svg', false)
->assertSee('data-uses-speak-access="1"', false);
$this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid])
->postJson(route('meet.room.speak.request', $session))
->assertOk()
->assertJsonPath('participant.speak_requested', true);
$attendee->refresh();
$this->assertTrue($attendee->speak_requested);
$this->assertFalse($attendee->speak_granted);
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
->postJson(route('meet.room.speak.grant', [$session, $attendee]))
->assertOk()
->assertJsonPath('participant.speak_granted', true)
->assertJsonPath('participant.speak_requested', false);
$attendee->refresh();
$this->assertTrue($attendee->speak_granted);
$this->assertTrue($sessions->canParticipantPublish($attendee));
$mediaState = $sessions->mediaSessionState($attendee);
$this->assertTrue($mediaState['can_publish']);
$this->assertTrue($mediaState['audio_only_publish']);
$this->assertTrue($mediaState['speak_granted']);
$this->withSession(["meet.participant.{$session->uuid}" => $attendee->uuid])
->get('/room/'.$session->uuid)
->assertOk()
->assertSee('You can speak', false)
->assertSee('data-speak-granted="1"', false);
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $host->uuid])
->postJson(route('meet.room.speak.revoke', [$session, $attendee]))
->assertOk()
->assertJsonPath('participant.speak_granted', false);
$attendee->refresh();
$this->assertFalse($sessions->canParticipantPublish($attendee));
}
public function test_webinar_show_events_create_link_uses_events_create_path(): void
{
config([
'meet.events_app_url' => 'https://events.test',
'meet.events_api_url' => 'https://events.test/api/service/v1',
'meet.events_api_key' => '',
]);
$room = Room::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'host_user_ref' => $this->user->public_id,
'title' => 'Sales webinar',
'type' => 'webinar',
'status' => 'ended',
'scheduled_at' => now()->subDay(),
'timezone' => 'UTC',
'settings' => config('meet.default_settings'),
]);
$this->actingAs($this->user)
->get(route('meet.webinars.show', $room))
->assertOk()
->assertSee('https://events.test/events/create', false)
->assertSee('Events linking is not configured on this Meet instance.', false)
->assertDontSee('/qr-codes/create', false);
}
public function test_afia_chat_returns_reply_when_ai_configured(): void
{
config([