From d648f6c86248f6facec83b94fc10dcf015a55685 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 2 Jul 2026 07:59:18 +0000 Subject: [PATCH] Add speak access for webinar/conference attendees and fix Events linking UX. 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 --- .env.example | 1 + .../Controllers/Meet/EventsLinkController.php | 2 +- .../Meet/MeetingRoomController.php | 66 +++++- app/Models/Participant.php | 13 +- app/Services/Meet/ConferenceService.php | 8 + app/Services/Meet/ParticipantPresenter.php | 2 + app/Services/Meet/SessionService.php | 12 +- app/Services/Meet/SpeakAccessService.php | 83 ++++++++ app/Services/Meet/WebinarService.php | 6 +- app/Support/EventsSourceLink.php | 10 + ..._add_speak_access_to_meet_participants.php | 23 ++ public/images/meet-icons/speak.svg | 4 + resources/js/meet-room.js | 199 +++++++++++++++++- .../views/meet/conferences/show.blade.php | 6 - resources/views/meet/events/link.blade.php | 3 +- .../partials/events-integration.blade.php | 22 +- .../meet/room/partials/meet-icon.blade.php | 1 + resources/views/meet/room/show.blade.php | 110 +++++++++- resources/views/meet/webinars/show.blade.php | 6 - resources/views/partials/sidebar.blade.php | 10 +- routes/web.php | 5 + tests/Feature/MeetWebTest.php | 126 +++++++++++ 22 files changed, 674 insertions(+), 44 deletions(-) create mode 100644 app/Services/Meet/SpeakAccessService.php create mode 100644 database/migrations/2026_07_02_120000_add_speak_access_to_meet_participants.php create mode 100644 public/images/meet-icons/speak.svg diff --git a/.env.example b/.env.example index aade2cc..219692a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Http/Controllers/Meet/EventsLinkController.php b/app/Http/Controllers/Meet/EventsLinkController.php index d7c9627..f2122fc 100644 --- a/app/Http/Controllers/Meet/EventsLinkController.php +++ b/app/Http/Controllers/Meet/EventsLinkController.php @@ -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()); diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 0dbec32..c22b4a8 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -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); diff --git a/app/Models/Participant.php b/app/Models/Participant.php index 616b7a0..2ce6b29 100644 --- a/app/Models/Participant.php +++ b/app/Models/Participant.php @@ -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'); diff --git a/app/Services/Meet/ConferenceService.php b/app/Services/Meet/ConferenceService.php index 2b5ecce..8344cc9 100644 --- a/app/Services/Meet/ConferenceService.php +++ b/app/Services/Meet/ConferenceService.php @@ -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); } diff --git a/app/Services/Meet/ParticipantPresenter.php b/app/Services/Meet/ParticipantPresenter.php index 3a4b6aa..4cf0b1a 100644 --- a/app/Services/Meet/ParticipantPresenter.php +++ b/app/Services/Meet/ParticipantPresenter.php @@ -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, diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php index c9df16a..0488ad8 100644 --- a/app/Services/Meet/SessionService.php +++ b/app/Services/Meet/SessionService.php @@ -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, diff --git a/app/Services/Meet/SpeakAccessService.php b/app/Services/Meet/SpeakAccessService.php new file mode 100644 index 0000000..e7bafa3 --- /dev/null +++ b/app/Services/Meet/SpeakAccessService.php @@ -0,0 +1,83 @@ +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(); + } +} diff --git a/app/Services/Meet/WebinarService.php b/app/Services/Meet/WebinarService.php index 8d3cd24..bd26b37 100644 --- a/app/Services/Meet/WebinarService.php +++ b/app/Services/Meet/WebinarService.php @@ -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; } } diff --git a/app/Support/EventsSourceLink.php b/app/Support/EventsSourceLink.php index c4c35c2..9292a5b 100644 --- a/app/Support/EventsSourceLink.php +++ b/app/Support/EventsSourceLink.php @@ -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); diff --git a/database/migrations/2026_07_02_120000_add_speak_access_to_meet_participants.php b/database/migrations/2026_07_02_120000_add_speak_access_to_meet_participants.php new file mode 100644 index 0000000..f89d12b --- /dev/null +++ b/database/migrations/2026_07_02_120000_add_speak_access_to_meet_participants.php @@ -0,0 +1,23 @@ +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']); + }); + } +}; diff --git a/public/images/meet-icons/speak.svg b/public/images/meet-icons/speak.svg new file mode 100644 index 0000000..917465f --- /dev/null +++ b/public/images/meet-icons/speak.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 1465c09..805c06c 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -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); diff --git a/resources/views/meet/conferences/show.blade.php b/resources/views/meet/conferences/show.blade.php index f13afaf..22fcfea 100644 --- a/resources/views/meet/conferences/show.blade.php +++ b/resources/views/meet/conferences/show.blade.php @@ -6,12 +6,6 @@

{{ config('meet.room_statuses')[$room->status] ?? $room->status }}

- @if ($errors->any()) -
- {{ $errors->first() }} -
- @endif - @if (session('success'))
{{ session('success') }} diff --git a/resources/views/meet/events/link.blade.php b/resources/views/meet/events/link.blade.php index e035042..0fb9f77 100644 --- a/resources/views/meet/events/link.blade.php +++ b/resources/views/meet/events/link.blade.php @@ -1,3 +1,4 @@ +@php use App\Support\EventsSourceLink; @endphp
← Back @@ -15,7 +16,7 @@ @if ($events === [])

No virtual or hybrid events found in your Ladill Events account.

- Create event in Events + Create event in Events
@else
diff --git a/resources/views/meet/partials/events-integration.blade.php b/resources/views/meet/partials/events-integration.blade.php index d137b96..ce8de73 100644 --- a/resources/views/meet/partials/events-integration.blade.php +++ b/resources/views/meet/partials/events-integration.blade.php @@ -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
+ @if (! $isLinked && ! $eventsConfigured) +
+

Events linking is not configured on this Meet instance.

+

+ Set MEET_API_KEY_EVENTS in Meet and the matching + EVENTS_API_KEY_MEET in Events so Meet can list and link your events. + You can still create an event in Events using the button below. +

+
+ @endif + @if ($isLinked)
@if ($eventsUrls['registration']) @@ -72,8 +84,12 @@

Programme speakers and panelists are configured on the event programme in Events and sync to this session when linked.

@else
- Link to Ladill Events - Create event in Events + @if ($eventsConfigured) + Link to Ladill Events + @else + Link to Ladill Events + @endif + Create event in Events
@endif
diff --git a/resources/views/meet/room/partials/meet-icon.blade.php b/resources/views/meet/room/partials/meet-icon.blade.php index eace17f..ba58d42 100644 --- a/resources/views/meet/room/partials/meet-icon.blade.php +++ b/resources/views/meet/room/partials/meet-icon.blade.php @@ -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; diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 5e24ce4..656d5c1 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -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 @@