Add speak access for webinar/conference attendees and fix Events linking UX.
Deploy Ladill Meet / deploy (push) Successful in 33s
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:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user