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
+
+
+
+