From 2fae2e7ed8cdf39d6417f2dd54e3da7f1b247b06 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 4 Jul 2026 00:50:58 +0000 Subject: [PATCH] Require acceptance for space speak invites and add instant co-host promotion. Remove duplicate host record button on desktop and split invite-to-speak from make co-host so listeners must accept before speaking while co-host is applied immediately. Co-authored-by: Cursor --- .../Meet/MeetingRoomController.php | 60 +++++++++- app/Services/Meet/SpaceService.php | 61 ++++++++++- resources/js/meet-room.js | 103 +++++++++++++++++- resources/views/meet/room/show.blade.php | 48 +++++++- routes/web.php | 6 +- tests/Feature/MeetWebTest.php | 74 ++++++++++++- 6 files changed, 338 insertions(+), 14 deletions(-) diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index c45d815..a3a9e55 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -252,14 +252,70 @@ class MeetingRoomController extends Controller ]); } - public function promoteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse + public function inviteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse { $actor = $this->currentParticipant($request, $session); abort_unless($participant->session_id === $session->id, 404); abort_unless($session->room->isSpace(), 404); abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); - $updated = app(SpaceService::class)->promoteToSpeaker($session->room, $participant); + $updated = app(SpaceService::class)->inviteToSpeak($session->room, $participant); + $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); + + return response()->json([ + 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), + ]); + } + + public function acceptSpaceSpeakInvitation(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + abort_unless($session->room->isSpace(), 404); + + $updated = app(SpaceService::class)->acceptSpeakInvitation($session->room, $participant); + $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); + + return response()->json([ + 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), + ]); + } + + public function declineSpaceSpeakInvitation(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + abort_unless($session->room->isSpace(), 404); + + $updated = app(SpaceService::class)->declineSpeakInvitation($session->room, $participant); + $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); + + return response()->json([ + 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), + ]); + } + + public function dismissSpaceSpeakInvitation(Request $request, Session $session, Participant $participant): JsonResponse + { + $actor = $this->currentParticipant($request, $session); + abort_unless($participant->session_id === $session->id, 404); + abort_unless($session->room->isSpace(), 404); + abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); + + $updated = app(SpaceService::class)->dismissSpeakInvitation($session->room, $participant); + $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); + + return response()->json([ + 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), + ]); + } + + public function promoteSpaceCoHost(Request $request, Session $session, Participant $participant): JsonResponse + { + $actor = $this->currentParticipant($request, $session); + abort_unless($participant->session_id === $session->id, 404); + abort_unless($session->room->isSpace(), 404); + abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); + + $updated = app(SpaceService::class)->promoteToCoHost($session->room, $participant); $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); return response()->json([ diff --git a/app/Services/Meet/SpaceService.php b/app/Services/Meet/SpaceService.php index ad7732c..22a46f1 100644 --- a/app/Services/Meet/SpaceService.php +++ b/app/Services/Meet/SpaceService.php @@ -72,6 +72,49 @@ class SpaceService ], true); } + public function inviteToSpeak(Room $room, Participant $participant): Participant + { + abort_unless($room->isSpace(), 422); + abort_unless(in_array($participant->role, ['attendee', 'guest', 'participant'], true), 422); + abort_unless(! $participant->speak_requested, 422, 'An invitation is already pending.'); + + $participant->update([ + 'speak_requested' => true, + 'hand_raised' => false, + ]); + + return $participant->fresh(); + } + + public function acceptSpeakInvitation(Room $room, Participant $participant): Participant + { + abort_unless($room->isSpace(), 422); + abort_unless(in_array($participant->role, ['attendee', 'guest', 'participant'], true), 422); + abort_unless($participant->speak_requested, 422, 'No speak invitation to accept.'); + + return $this->promoteToSpeaker($room, $participant); + } + + public function declineSpeakInvitation(Room $room, Participant $participant): Participant + { + abort_unless($room->isSpace(), 422); + abort_unless($participant->speak_requested, 422); + + $participant->update(['speak_requested' => false]); + + return $participant->fresh(); + } + + public function dismissSpeakInvitation(Room $room, Participant $participant): Participant + { + abort_unless($room->isSpace(), 422); + abort_unless($participant->speak_requested, 422); + + $participant->update(['speak_requested' => false]); + + return $participant->fresh(); + } + public function promoteToSpeaker(Room $room, Participant $participant): Participant { abort_unless($room->isSpace(), 422); @@ -80,6 +123,7 @@ class SpaceService $participant->update([ 'role' => 'panelist', 'hand_raised' => false, + 'speak_requested' => false, ]); if ($participant->user_ref) { @@ -99,12 +143,25 @@ class SpaceService return $participant->fresh(); } - public function demoteFromSpeaker(Room $room, Participant $participant): Participant + public function promoteToCoHost(Room $room, Participant $participant): Participant { abort_unless($room->isSpace(), 422); abort_unless($participant->role === 'panelist', 422); - $participant->update(['role' => 'attendee']); + $participant->update(['role' => 'co_host']); + + return $participant->fresh(); + } + + public function demoteFromSpeaker(Room $room, Participant $participant): Participant + { + abort_unless($room->isSpace(), 422); + abort_unless(in_array($participant->role, ['panelist', 'co_host'], true), 422); + + $participant->update([ + 'role' => 'attendee', + 'speak_requested' => false, + ]); if ($participant->user_ref) { $speakerRefs = collect((array) $room->setting('speaker_refs', [])) diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 80d7eaf..4104435 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -124,6 +124,10 @@ function meetRoom() { speakDismissUrl: el.dataset.speakDismissUrl, spacePromoteUrl: el.dataset.spacePromoteUrl, spaceDemoteUrl: el.dataset.spaceDemoteUrl, + spaceSpeakAcceptUrl: el.dataset.spaceSpeakAcceptUrl, + spaceSpeakDeclineUrl: el.dataset.spaceSpeakDeclineUrl, + spaceSpeakDismissUrl: el.dataset.spaceSpeakDismissUrl, + spaceCoHostUrl: el.dataset.spaceCoHostUrl, recordingStartUrl: el.dataset.recordingStartUrl, recordingStopUrl: el.dataset.recordingStopUrl, lockUrl: el.dataset.lockUrl, @@ -2175,7 +2179,18 @@ function meetRoom() { }, handRaisedParticipants() { - return this.inCallParticipants().filter((person) => person.hand_raised && person.role === 'attendee'); + return this.inCallParticipants().filter((person) => ( + person.hand_raised + && person.role === 'attendee' + && !person.speak_requested + )); + }, + + spaceSpeakInvitePendingParticipants() { + return this.inCallParticipants().filter((person) => ( + person.role === 'attendee' + && person.speak_requested + )); }, spaceSpeakerUrl(template, participantUuid) { @@ -2194,10 +2209,19 @@ function meetRoom() { if (participant.uuid === this.config.participantUuid) { this.canPublish = ['host', 'co_host', 'panelist'].includes(participant.role); + this.speakRequested = Boolean(participant.speak_requested); } }, - async promoteSpaceSpeaker(participantUuid) { + async refreshMediaAccess() { + if (!this.config.configured) { + return; + } + + await this.reconnectLiveKit(); + }, + + async inviteSpaceSpeaker(participantUuid) { const url = this.spaceSpeakerUrl(this.config.spacePromoteUrl, participantUuid); if (!this.isSpace || !this.isHost || !url) { return; @@ -2216,6 +2240,81 @@ function meetRoom() { this.mergeSessionParticipant(data.participant); }, + async acceptSpaceSpeakInvitation() { + if (!this.isSpace || !this.config.spaceSpeakAcceptUrl) { + return; + } + + const res = await fetch(this.config.spaceSpeakAcceptUrl, { + method: 'POST', + headers: this.headers(), + }); + + if (!res.ok) { + return; + } + + const data = await res.json(); + this.mergeSessionParticipant(data.participant); + await this.refreshMediaAccess(); + }, + + async declineSpaceSpeakInvitation() { + if (!this.isSpace || !this.config.spaceSpeakDeclineUrl) { + return; + } + + const res = await fetch(this.config.spaceSpeakDeclineUrl, { + method: 'POST', + headers: this.headers(), + }); + + if (!res.ok) { + return; + } + + const data = await res.json(); + this.mergeSessionParticipant(data.participant); + }, + + async dismissSpaceSpeakInvitation(participantUuid) { + const url = this.spaceSpeakerUrl(this.config.spaceSpeakDismissUrl, participantUuid); + if (!this.isSpace || !this.isHost || !url) { + return; + } + + const res = await fetch(url, { + method: 'POST', + headers: this.headers(), + }); + + if (!res.ok) { + return; + } + + const data = await res.json(); + this.mergeSessionParticipant(data.participant); + }, + + async promoteSpaceCoHost(participantUuid) { + const url = this.spaceSpeakerUrl(this.config.spaceCoHostUrl, participantUuid); + if (!this.isSpace || !this.isHost || !url) { + return; + } + + const res = await fetch(url, { + method: 'POST', + headers: this.headers(), + }); + + if (!res.ok) { + return; + } + + const data = await res.json(); + this.mergeSessionParticipant(data.participant); + }, + async demoteSpaceSpeaker(participantUuid) { const url = this.spaceSpeakerUrl(this.config.spaceDemoteUrl, participantUuid); if (!this.isSpace || !this.isHost || !url) { diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 2e1e443..213aa56 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -84,6 +84,10 @@ data-speak-dismiss-url="{{ ($canManageSpeakAccess ?? false) ? route('meet.room.speak.dismiss', [$session, '__UUID__']) : '' }}" data-space-promote-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.promote', [$session, '__UUID__']) : '' }}" data-space-demote-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.demote', [$session, '__UUID__']) : '' }}" + data-space-speak-accept-url="{{ ($isSpace ?? false) ? route('meet.room.space.speak.accept', $session) : '' }}" + data-space-speak-decline-url="{{ ($isSpace ?? false) ? route('meet.room.space.speak.decline', $session) : '' }}" + data-space-speak-dismiss-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.speak.dismiss', [$session, '__UUID__']) : '' }}" + data-space-co-host-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.co-host', [$session, '__UUID__']) : '' }}" data-uses-speak-access="{{ ($usesSpeakAccess ?? false) ? '1' : '0' }}" data-can-manage-speak="{{ ($canManageSpeakAccess ?? false) ? '1' : '0' }}" data-leave-url="{{ route('meet.room.leave', $session) }}" @@ -368,6 +372,16 @@ You can speak — your microphone is available

+
+
+ You've been invited to speak + + +
+
@@ -434,7 +448,7 @@ @if ($participant->isHost())
+