diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 7537226..c45d815 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -252,6 +252,36 @@ class MeetingRoomController extends Controller ]); } + public function promoteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse + { + $actor = $this->currentParticipant($request, $session); + abort_unless($participant->session_id === $session->id, 404); + abort_unless($session->room->isSpace(), 404); + abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); + + $updated = app(SpaceService::class)->promoteToSpeaker($session->room, $participant); + $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); + + return response()->json([ + 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), + ]); + } + + public function demoteSpaceSpeaker(Request $request, Session $session, Participant $participant): JsonResponse + { + $actor = $this->currentParticipant($request, $session); + abort_unless($participant->session_id === $session->id, 404); + abort_unless($session->room->isSpace(), 404); + abort_unless($actor->isHost() || ($request->user() && $request->user()->ownerRef() === $session->room->host_user_ref), 403); + + $updated = app(SpaceService::class)->demoteFromSpeaker($session->room, $participant); + $avatars = ParticipantPresenter::avatarMap($session->participants()->get(), $session->room->host_user_ref); + + return response()->json([ + 'participant' => ParticipantPresenter::toArray($updated, $avatars, $session->room->host_user_ref), + ]); + } + public function sendMessage(Request $request, Session $session): JsonResponse { abort_unless(app(SpaceService::class)->allowsRoomFeature($session->room, 'chat'), 422); diff --git a/app/Services/Meet/SpaceService.php b/app/Services/Meet/SpaceService.php index 7474c99..ad7732c 100644 --- a/app/Services/Meet/SpaceService.php +++ b/app/Services/Meet/SpaceService.php @@ -71,4 +71,54 @@ class SpaceService 'lock', ], true); } + + public function promoteToSpeaker(Room $room, Participant $participant): Participant + { + abort_unless($room->isSpace(), 422); + abort_unless(in_array($participant->role, ['attendee', 'guest', 'participant'], true), 422); + + $participant->update([ + 'role' => 'panelist', + 'hand_raised' => false, + ]); + + if ($participant->user_ref) { + $speakerRefs = collect((array) $room->setting('speaker_refs', [])) + ->push($participant->user_ref) + ->unique() + ->values() + ->all(); + + $room->update([ + 'settings' => array_merge($room->settings ?? [], [ + 'speaker_refs' => $speakerRefs, + ]), + ]); + } + + return $participant->fresh(); + } + + public function demoteFromSpeaker(Room $room, Participant $participant): Participant + { + abort_unless($room->isSpace(), 422); + abort_unless($participant->role === 'panelist', 422); + + $participant->update(['role' => 'attendee']); + + if ($participant->user_ref) { + $speakerRefs = collect((array) $room->setting('speaker_refs', [])) + ->reject(fn (string $ref) => $ref === $participant->user_ref) + ->values() + ->all(); + + $room->update([ + 'settings' => array_merge($room->settings ?? [], [ + 'speaker_refs' => $speakerRefs, + ]), + ]); + } + + return $participant->fresh(); + } } diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index e207ddd..80d7eaf 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -122,6 +122,8 @@ function meetRoom() { speakGrantUrl: el.dataset.speakGrantUrl, speakRevokeUrl: el.dataset.speakRevokeUrl, speakDismissUrl: el.dataset.speakDismissUrl, + spacePromoteUrl: el.dataset.spacePromoteUrl, + spaceDemoteUrl: el.dataset.spaceDemoteUrl, recordingStartUrl: el.dataset.recordingStartUrl, recordingStopUrl: el.dataset.recordingStopUrl, lockUrl: el.dataset.lockUrl, @@ -2172,6 +2174,67 @@ function meetRoom() { return this.inCallParticipants().filter((person) => person.speak_requested && !person.speak_granted); }, + handRaisedParticipants() { + return this.inCallParticipants().filter((person) => person.hand_raised && person.role === 'attendee'); + }, + + spaceSpeakerUrl(template, participantUuid) { + return template?.replace('__UUID__', participantUuid) || ''; + }, + + mergeSessionParticipant(participant) { + if (!participant?.uuid) { + return; + } + + const index = this.sessionParticipants.findIndex((person) => person.uuid === participant.uuid); + if (index >= 0) { + this.sessionParticipants[index] = { ...this.sessionParticipants[index], ...participant }; + } + + if (participant.uuid === this.config.participantUuid) { + this.canPublish = ['host', 'co_host', 'panelist'].includes(participant.role); + } + }, + + async promoteSpaceSpeaker(participantUuid) { + const url = this.spaceSpeakerUrl(this.config.spacePromoteUrl, participantUuid); + if (!this.isSpace || !this.isHost || !url) { + return; + } + + const res = await fetch(url, { + method: 'POST', + headers: this.headers(), + }); + + if (!res.ok) { + return; + } + + const data = await res.json(); + this.mergeSessionParticipant(data.participant); + }, + + async demoteSpaceSpeaker(participantUuid) { + const url = this.spaceSpeakerUrl(this.config.spaceDemoteUrl, participantUuid); + if (!this.isSpace || !this.isHost || !url) { + return; + } + + const res = await fetch(url, { + method: 'DELETE', + headers: this.headers(), + }); + + if (!res.ok) { + return; + } + + const data = await res.json(); + this.mergeSessionParticipant(data.participant); + }, + syncSelfSpeakState() { const self = this.sessionParticipants.find((person) => person.uuid === this.config.participantUuid); if (!self) { diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 2b5cd32..2e1e443 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -82,6 +82,8 @@ 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-space-promote-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.promote', [$session, '__UUID__']) : '' }}" + data-space-demote-url="{{ ($isSpace ?? false) && ($participant->isHost() || ($canManageConference ?? false)) ? route('meet.room.space.demote', [$session, '__UUID__']) : '' }}" data-uses-speak-access="{{ ($usesSpeakAccess ?? false) ? '1' : '0' }}" data-can-manage-speak="{{ ($canManageSpeakAccess ?? false) ? '1' : '0' }}" data-leave-url="{{ route('meet.room.leave', $session) }}" @@ -422,7 +424,24 @@ title="React"> @include('meet.room.partials.meet-icon', ['icon' => 'react']) + + @if ($participant->isHost()) + + @endif + @if (($isSpace ?? false) && $participant->isHost()) + + @endif @@ -604,10 +632,31 @@ -

+

+