diff --git a/app/Http/Controllers/Meet/ConferenceController.php b/app/Http/Controllers/Meet/ConferenceController.php new file mode 100644 index 0000000..d6e301d --- /dev/null +++ b/app/Http/Controllers/Meet/ConferenceController.php @@ -0,0 +1,165 @@ +authorizeAbility($request, 'meetings.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $query = Room::owned($owner) + ->where('organization_id', $organization->id) + ->where('type', 'town_hall'); + + $this->scopeToBranch($request, $query); + + $conferences = $query->orderByDesc('scheduled_at')->paginate(20); + + return view('meet.conferences.index', compact('conferences', 'organization')); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $templates = Template::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->orderBy('name') + ->get(); + + return view('meet.conferences.create', [ + 'organization' => $organization, + 'templates' => $templates, + 'timezones' => timezone_identifiers_list(), + 'defaultSettings' => config('meet.default_settings'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'scheduled_at' => ['required', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + 'timezone' => ['nullable', 'timezone'], + 'passcode' => ['nullable', 'string', 'max:20'], + 'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'], + 'invite_emails' => ['nullable', 'string'], + 'waiting_room' => ['sometimes', 'boolean'], + 'join_before_host' => ['sometimes', 'boolean'], + 'mute_on_join' => ['sometimes', 'boolean'], + 'auto_record' => ['sometimes', 'boolean'], + 'live_captions' => ['sometimes', 'boolean'], + 'green_room' => ['sometimes', 'boolean'], + 'practice_mode' => ['sometimes', 'boolean'], + ]); + + $settings = [ + 'waiting_room' => $request->boolean('waiting_room', true), + 'join_before_host' => $request->boolean('join_before_host'), + 'mute_on_join' => $request->boolean('mute_on_join', true), + 'auto_record' => $request->boolean('auto_record'), + 'live_captions' => $request->boolean('live_captions'), + 'green_room' => $request->boolean('green_room', true), + 'practice_mode' => $request->boolean('practice_mode'), + 'stage_mode' => true, + ]; + + $data = array_merge($validated, [ + 'settings' => $settings, + 'timezone' => $validated['timezone'] ?? $organization->timezone, + 'type' => 'town_hall', + 'status' => 'scheduled', + ]); + + if (! empty($validated['template_id'])) { + $template = Template::findOrFail($validated['template_id']); + $data = $this->templates->applyToRoomData($template, $data); + } + + $room = $this->rooms->create($request->user(), $organization, $data); + + if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) { + $invites = collect(preg_split('/[\s,;]+/', $emails)) + ->filter() + ->map(fn ($email) => ['email' => $email]) + ->all(); + $this->invitations->inviteToRoom($room, $invites, $request->user()); + } + + if ($room->scheduled_at) { + $this->calendar->syncRoomCreated($room, $request->user()); + } + + return redirect()->route('meet.conferences.show', $room)->with('success', 'Conference created.'); + } + + public function show(Request $request, Room $room): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + abort_unless($room->isConference(), 404); + + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']); + + return view('meet.conferences.show', compact('room')); + } + + public function start(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.host'); + $this->authorizeOwner($request, $room); + abort_unless($room->isConference(), 404); + + if ($room->status === 'cancelled') { + return redirect()->route('meet.conferences.show', $room) + ->withErrors(['start' => 'Conference was cancelled.']); + } + + if ($room->setting('green_room')) { + $session = $this->townHalls->startGreenRoom($room, $request->user()); + } else { + $session = $this->sessions->start($room, $request->user()); + } + + $participant = $this->sessions->joinedParticipantForUser($session, $request->user()); + abort_unless($participant, 500, 'Unable to join as host.'); + + $this->sessions->rememberParticipantSession($request, $participant, $session); + + return redirect()->route('meet.room', $session); + } +} diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index ebf3324..3659980 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -9,6 +9,7 @@ use App\Models\Session; use App\Services\Meet\InvitationService; use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SessionService; +use App\Services\Meet\SpaceService; use App\Services\Meet\WebinarService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -22,6 +23,7 @@ class JoinController extends Controller protected SessionService $sessions, protected SecurityPolicyService $security, protected WebinarService $webinars, + protected SpaceService $spaces, ) {} public function show(Request $request, Room $room): View|RedirectResponse @@ -155,7 +157,9 @@ class JoinController extends Controller $role = ($user && $user->ownerRef() === $room->host_user_ref) ? 'host' : 'guest'; - if ($room->isWebinar() && $role !== 'host') { + if ($room->isSpace()) { + $role = $this->spaces->resolveJoinRole($room, $user, $role); + } elseif ($room->isWebinar() && $role !== 'host') { $token = $request->session()->get("meet.webinar.{$room->uuid}"); $reg = $token ? $this->webinars->findByAccessToken($token) diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 4a71351..fbe27fe 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -12,7 +12,9 @@ use App\Services\Meet\ParticipantPresenter; use App\Services\Meet\RecordingService; use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SessionService; +use App\Services\Meet\SpaceService; use App\Services\Meet\TranscriptService; +use App\Services\Meet\WebinarService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -54,19 +56,28 @@ class MeetingRoomController extends Controller $session->load(['room', 'participants']); + $room = $session->room; + $webinars = app(WebinarService::class); + $spaces = app(SpaceService::class); + $isAudioOnly = $room->isAudioOnly(); + return view('meet.room.show', [ 'session' => $session, - 'room' => $session->room, + 'room' => $room, 'participant' => $participant, 'mediaToken' => $token, 'mediaUrl' => $this->media->serverUrl(), 'mediaConfigured' => $this->media->isConfigured(), 'messages' => $this->chat->recentMessages($session), 'activeRecording' => $session->recordings()->where('status', 'recording')->first(), - 'watermark' => $session->room->organization?->securitySetting('watermark_enabled', false), - 'isWebinar' => $session->room->isWebinar(), - 'canPublish' => app(\App\Services\Meet\WebinarService::class)->canPublish($session->room, $participant), - 'isAttendee' => $participant->isAttendee(), + 'watermark' => $room->organization?->securitySetting('watermark_enabled', false), + 'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(), + 'isAudioOnly' => $isAudioOnly, + 'isSpace' => $room->isSpace(), + 'canPublish' => $room->isSpace() + ? $spaces->canPublish($room, $participant) + : $webinars->canPublish($room, $participant), + 'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant), ]); } diff --git a/app/Http/Controllers/Meet/RoomController.php b/app/Http/Controllers/Meet/RoomController.php index 4e40807..f824054 100644 --- a/app/Http/Controllers/Meet/RoomController.php +++ b/app/Http/Controllers/Meet/RoomController.php @@ -45,7 +45,7 @@ class RoomController extends Controller $owner = $this->ownerRef($request); $query = Room::owned($owner)->where('organization_id', $organization->id) - ->where('type', '!=', 'webinar'); + ->whereNotIn('type', ['webinar', 'town_hall', 'space']); $this->scopeToBranch($request, $query); $rooms = $query->orderByDesc('scheduled_at')->paginate(20); @@ -80,7 +80,7 @@ class RoomController extends Controller $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:2000'], - 'type' => ['nullable', 'string', 'in:scheduled,town_hall'], + 'type' => ['nullable', 'string', 'in:scheduled'], 'scheduled_at' => ['nullable', 'date'], 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], 'timezone' => ['nullable', 'timezone'], @@ -122,16 +122,6 @@ class RoomController extends Controller if (! empty($validated['recurrence_rule']) && ! $isInstant) { $room = $this->recurring->createSeries($request->user(), $organization, $data); - } elseif (($validated['type'] ?? '') === 'town_hall') { - abort_if($isInstant, 422, 'Town halls must be scheduled.'); - $room = $this->rooms->create($request->user(), $organization, array_merge($data, [ - 'type' => 'town_hall', - 'status' => 'scheduled', - 'settings' => array_merge($settings, [ - 'green_room' => true, - 'practice_mode' => $request->boolean('practice_mode'), - ]), - ])); } elseif ($isInstant) { $room = $this->rooms->createInstant($request->user(), $organization, $data); } else { @@ -162,6 +152,14 @@ class RoomController extends Controller return redirect()->route('meet.webinars.show', $room); } + if ($room->isConference()) { + return redirect()->route('meet.conferences.show', $room); + } + + if ($room->isSpace()) { + return redirect()->route('meet.spaces.show', $room); + } + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']); return view('meet.rooms.show', compact('room')); diff --git a/app/Http/Controllers/Meet/SpaceController.php b/app/Http/Controllers/Meet/SpaceController.php new file mode 100644 index 0000000..30e1cee --- /dev/null +++ b/app/Http/Controllers/Meet/SpaceController.php @@ -0,0 +1,182 @@ +authorizeAbility($request, 'meetings.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $query = Room::owned($owner) + ->where('organization_id', $organization->id) + ->where('type', 'space'); + + $this->scopeToBranch($request, $query); + + $spaces = $query->orderByDesc('updated_at')->paginate(20); + + return view('meet.spaces.index', compact('spaces', 'organization')); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $templates = Template::owned($owner) + ->where('organization_id', $organization->id) + ->orderBy('name') + ->get(); + + $members = Member::owned($owner) + ->where('organization_id', $organization->id) + ->where('user_ref', '!=', $owner) + ->orderBy('user_ref') + ->get(); + + return view('meet.spaces.create', [ + 'organization' => $organization, + 'templates' => $templates, + 'members' => $members, + 'timezones' => timezone_identifiers_list(), + 'defaultSettings' => config('meet.default_settings'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'scheduled_at' => ['nullable', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + 'timezone' => ['nullable', 'timezone'], + 'passcode' => ['nullable', 'string', 'max:20'], + 'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'], + 'invite_emails' => ['nullable', 'string'], + 'speaker_refs' => ['nullable', 'array'], + 'speaker_refs.*' => ['string', 'max:64'], + 'invite_only' => ['sometimes', 'boolean'], + 'mute_listeners' => ['sometimes', 'boolean'], + ]); + + $speakerRefs = collect($validated['speaker_refs'] ?? []) + ->filter() + ->unique() + ->values() + ->all(); + + $settings = [ + 'waiting_room' => false, + 'join_before_host' => false, + 'mute_on_join' => $request->boolean('mute_listeners', true), + 'video_off_on_join' => true, + 'audio_only' => true, + 'space_mode' => true, + 'stage_mode' => true, + 'invite_only' => $request->boolean('invite_only', true), + 'speaker_refs' => $speakerRefs, + ]; + + $data = array_merge($validated, [ + 'settings' => $settings, + 'timezone' => $validated['timezone'] ?? $organization->timezone, + 'type' => 'space', + 'status' => empty($validated['scheduled_at']) ? 'scheduled' : 'scheduled', + ]); + + unset($data['speaker_refs'], $data['invite_only'], $data['mute_listeners']); + + if (! empty($validated['template_id'])) { + $template = Template::findOrFail($validated['template_id']); + $data = $this->templates->applyToRoomData($template, $data); + } + + $room = $this->rooms->create($request->user(), $organization, $data); + + if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) { + $invites = collect(preg_split('/[\s,;]+/', $emails)) + ->filter() + ->map(fn ($email) => ['email' => $email]) + ->all(); + $this->invitations->inviteToRoom($room, $invites, $request->user()); + } + + if (! empty($room->scheduled_at)) { + $this->calendar->syncRoomCreated($room, $request->user()); + } + + return redirect()->route('meet.spaces.show', $room)->with('success', 'Room created.'); + } + + public function show(Request $request, Room $room): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + abort_unless($room->isSpace(), 404); + + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']); + + $speakerRefs = (array) $room->setting('speaker_refs', []); + $speakers = Member::owned($this->ownerRef($request)) + ->where('organization_id', $room->organization_id) + ->whereIn('user_ref', $speakerRefs) + ->get(); + + return view('meet.spaces.show', compact('room', 'speakers')); + } + + public function start(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.host'); + $this->authorizeOwner($request, $room); + abort_unless($room->isSpace(), 404); + + if ($room->status === 'cancelled') { + return redirect()->route('meet.spaces.show', $room) + ->withErrors(['start' => 'Room was closed.']); + } + + $session = $this->sessions->start($room, $request->user()); + + $participant = $this->sessions->joinedParticipantForUser($session, $request->user()); + abort_unless($participant, 500, 'Unable to open room as host.'); + + $this->sessions->rememberParticipantSession($request, $participant, $session); + + return redirect()->route('meet.room', $session); + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php index bc76ea1..45918bf 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -81,6 +81,21 @@ class Room extends Model return $this->type === 'webinar'; } + public function isConference(): bool + { + return $this->type === 'town_hall'; + } + + public function isSpace(): bool + { + return $this->type === 'space'; + } + + public function isAudioOnly(): bool + { + return $this->isSpace() || (bool) $this->setting('audio_only', false); + } + public function canRestart(): bool { if ($this->status === 'cancelled' || $this->activeSession()) { @@ -100,6 +115,14 @@ class Room extends Model return $restarting ? 'Restart webinar' : 'Start webinar'; } + if ($this->isConference()) { + return $restarting ? 'Restart conference' : 'Start conference'; + } + + if ($this->isSpace()) { + return $restarting ? 'Reopen room' : 'Open room'; + } + return $restarting ? 'Restart meeting' : 'Start meeting'; } diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php index 7a8c0a9..1aa9867 100644 --- a/app/Services/Meet/SessionService.php +++ b/app/Services/Meet/SessionService.php @@ -262,8 +262,11 @@ class SessionService $isHost = $participant->isHost(); $breakoutService = app(BreakoutService::class); $webinarService = app(WebinarService::class); + $spaceService = app(\App\Services\Meet\SpaceService::class); $mediaRoom = $breakoutService->mediaRoomForParticipant($participant); - $canPublish = $webinarService->canPublish($room, $participant) && $participant->canPublishMedia(); + $canPublish = ($room->isSpace() + ? $spaceService->canPublish($room, $participant) + : $webinarService->canPublish($room, $participant)) && $participant->canPublishMedia(); return $this->media->generateToken( $mediaRoom, diff --git a/app/Services/Meet/SpaceService.php b/app/Services/Meet/SpaceService.php new file mode 100644 index 0000000..501da70 --- /dev/null +++ b/app/Services/Meet/SpaceService.php @@ -0,0 +1,51 @@ +type !== 'space') { + return $fallbackRole; + } + + if ($user && $user->ownerRef() === $room->host_user_ref) { + return 'host'; + } + + $speakerRefs = (array) $room->setting('speaker_refs', []); + + if ($user && in_array($user->ownerRef(), $speakerRefs, true)) { + return 'panelist'; + } + + return 'attendee'; + } + + public function canPublish(Room $room, Participant $participant): bool + { + if ($room->type !== 'space') { + return true; + } + + return in_array($participant->role, ['host', 'co_host', 'panelist'], true); + } + + public function isListener(Room $room, Participant $participant): bool + { + return $room->type === 'space' && $participant->role === 'attendee'; + } + + public function isAudioOnly(Room $room): bool + { + return $room->isSpace() || (bool) $room->setting('audio_only', false); + } +} diff --git a/app/Services/Meet/WebinarService.php b/app/Services/Meet/WebinarService.php index 68039ce..8d3cd24 100644 --- a/app/Services/Meet/WebinarService.php +++ b/app/Services/Meet/WebinarService.php @@ -63,7 +63,7 @@ class WebinarService public function canPublish(Room $room, Participant $participant): bool { - if ($room->type !== 'webinar') { + if (! in_array($room->type, ['webinar', 'town_hall'], true)) { return true; } diff --git a/config/meet.php b/config/meet.php index 16ac940..a6b5001 100644 --- a/config/meet.php +++ b/config/meet.php @@ -16,7 +16,8 @@ return [ 'personal' => 'Personal room', 'recurring' => 'Recurring meeting', 'webinar' => 'Webinar', - 'town_hall' => 'Town hall', + 'town_hall' => 'Conference', + 'space' => 'Room', ], 'room_statuses' => [ diff --git a/resources/css/app.css b/resources/css/app.css index ae92610..576908a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -45,6 +45,19 @@ html.meet-room-page { box-shadow: 0 0 0 2px rgb(52 211 153 / 0.9); } +.meet-room-page .meet-audio-grid { + place-content: center; + place-items: center; + grid-template-columns: repeat(auto-fill, minmax(7rem, 9rem)); + gap: 1rem; +} + +.meet-room-page .meet-audio-tile [data-tile-avatar-circle] { + height: 4.5rem; + width: 4.5rem; + font-size: 1.25rem; +} + .meet-room-page .meet-speaking-bar { transform-origin: center bottom; animation: meet-speaking-pulse 0.9s ease-in-out infinite; diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 049ad39..bc7a6fc 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -38,6 +38,8 @@ function meetRoom() { needsMediaUnlock: false, waitingCount: 0, isWebinar: false, + audioOnly: false, + isSpace: false, qaItems: [], activePolls: [], breakoutBroadcast: '', @@ -105,8 +107,10 @@ function meetRoom() { this.isHost = el.dataset.isHost === '1'; this.canPublish = el.dataset.canPublish !== '0'; this.isWebinar = el.dataset.isWebinar === '1'; + this.audioOnly = el.dataset.audioOnly === '1'; + this.isSpace = el.dataset.isSpace === '1'; this.muteOnJoin = el.dataset.muteOnJoin === '1'; - this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1'; + this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1' || this.audioOnly; this.isRecording = el.dataset.recordingActive === '1'; this.isLocked = el.dataset.locked === '1'; this.watermark = el.dataset.watermark === '1'; @@ -275,7 +279,7 @@ function meetRoom() { if (this.canPublish) { await this.enableLocalMedia(room); } else { - this.connectionStatus = 'View-only audience mode'; + this.connectionStatus = this.audioOnly ? 'Listening' : 'View-only audience mode'; this.isMuted = true; this.isVideoOff = true; } @@ -315,7 +319,7 @@ function meetRoom() { async enableLocalMedia(activeRoom, attempts = 3) { const wantMic = !this.muteOnJoin || this.isHost; - const wantCamera = !this.videoOffOnJoin; + const wantCamera = !this.videoOffOnJoin && !this.audioOnly; const audioOptions = { echoCancellation: true, noiseSuppression: true, @@ -651,7 +655,9 @@ function meetRoom() { if (!tile) { tile = document.createElement('div'); tile.dataset.participantTile = identity; - tile.className = 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900'; + tile.className = this.audioOnly + ? 'meet-audio-tile relative flex aspect-square w-full max-w-[9rem] flex-col items-center justify-center overflow-hidden rounded-2xl bg-slate-900' + : 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900'; tile.innerHTML = `
@@ -716,7 +722,7 @@ function meetRoom() { const hasVideo = this.isParticipantVideoActive(participant); micBadge.classList.toggle('hidden', !this.isParticipantMuted(participant)); - camBadge.classList.toggle('hidden', hasVideo); + camBadge.classList.toggle('hidden', hasVideo || this.audioOnly); if (hasVideo) { avatar.classList.add('hidden'); @@ -1032,6 +1038,8 @@ function meetRoom() { } participant.videoTrackPublications.forEach((pub) => { + if (this.audioOnly) return; + if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) { this.attachTrack(pub.track, pub, participant); } @@ -1061,6 +1069,11 @@ function meetRoom() { if (track.kind !== Track.Kind.Video) return; + if (this.audioOnly) { + this.updateParticipantTile(participant); + return; + } + const isScreen = publication?.source === Track.Source.ScreenShare || track.source === Track.Source.ScreenShare; @@ -1151,7 +1164,7 @@ function meetRoom() { }, async toggleVideo() { - if (!room) return; + if (!room || this.audioOnly) return; await this.ensureAudioPlayback(); const enabled = room.localParticipant.isCameraEnabled; await room.localParticipant.setCameraEnabled(!enabled); @@ -1170,7 +1183,7 @@ function meetRoom() { }, async toggleScreenShare() { - if (!room) return; + if (!room || this.audioOnly) return; this.isScreenSharing = !this.isScreenSharing; await room.localParticipant.setScreenShareEnabled(this.isScreenSharing); if (!this.isScreenSharing) { diff --git a/resources/views/meet/conferences/create.blade.php b/resources/views/meet/conferences/create.blade.php new file mode 100644 index 0000000..749d0f8 --- /dev/null +++ b/resources/views/meet/conferences/create.blade.php @@ -0,0 +1,73 @@ + +
+

Schedule a conference

+

Large-format events with assigned hosts and speakers. Optional green room for presenters before going live.

+ +
+ @csrf + + @if ($templates->isNotEmpty()) +
+ + +
+ @endif + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ Options + + + + + +
+ + +
+
+
diff --git a/resources/views/meet/conferences/index.blade.php b/resources/views/meet/conferences/index.blade.php new file mode 100644 index 0000000..c62a3b1 --- /dev/null +++ b/resources/views/meet/conferences/index.blade.php @@ -0,0 +1,22 @@ + +
+
+

Conferences

+

Multi-speaker events with hosts and presenters. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant.

+
+ Schedule conference +
+ +
+ @forelse ($conferences as $room) + @include('meet.partials.room-card', [ + 'room' => $room, + 'detailsRoute' => route('meet.conferences.show', $room), + ]) + @empty +

No conferences yet.

+ @endforelse +
+ +
{{ $conferences->links() }}
+
diff --git a/resources/views/meet/conferences/show.blade.php b/resources/views/meet/conferences/show.blade.php new file mode 100644 index 0000000..c7b217e --- /dev/null +++ b/resources/views/meet/conferences/show.blade.php @@ -0,0 +1,81 @@ + +
+
+

Conference

+

{{ $room->title }}

+

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

+
+ + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+ Multi-speaker event with assigned hosts and presenters. + Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant. + @if ($room->setting('green_room')) + Green room is enabled — presenters rehearse before the audience joins live. + @endif +
+ +
+

Join link

+
+ + +
+ +
+ @if ($room->status === 'live' && $room->activeSession()) + Join conference + @elseif ($room->status === 'scheduled') +
+ @csrf + +
+ @elseif ($room->canRestart()) +
+ @csrf + +
+ @endif + Download iCal + QR code + @if ($room->sessions->isNotEmpty()) + Attendance CSV + @endif + Manage invitations +
+ + @if ($room->passcode) +

Passcode: {{ $room->passcode }}

+ @endif + + @if ($room->scheduled_at) +

+ Scheduled: {{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }} +

+ @endif + + @if ($room->description) +

{{ $room->description }}

+ @endif +
+ + @if ($room->sessions->isNotEmpty()) +
+

Sessions

+
    + @foreach ($room->sessions as $session) +
  • + {{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }} + {{ $session->peak_participants }} participants · {{ config('meet.session_statuses')[$session->status] ?? $session->status }} +
  • + @endforeach +
+
+ @endif +
+
diff --git a/resources/views/meet/invitations/index.blade.php b/resources/views/meet/invitations/index.blade.php index 4ec0bfb..9ae0a14 100644 --- a/resources/views/meet/invitations/index.blade.php +++ b/resources/views/meet/invitations/index.blade.php @@ -4,7 +4,21 @@

Invitations

{{ $room->title }}

- ← Back to {{ $room->isWebinar() ? 'webinar' : 'meeting' }} + @php + $backRoute = match (true) { + $room->isWebinar() => route('meet.webinars.show', $room), + $room->isConference() => route('meet.conferences.show', $room), + $room->isSpace() => route('meet.spaces.show', $room), + default => route('meet.rooms.show', $room), + }; + $backLabel = match (true) { + $room->isWebinar() => 'webinar', + $room->isConference() => 'conference', + $room->isSpace() => 'room', + default => 'meeting', + }; + @endphp + ← Back to {{ $backLabel }}
diff --git a/resources/views/meet/partials/room-card.blade.php b/resources/views/meet/partials/room-card.blade.php index 48d9aec..9531e80 100644 --- a/resources/views/meet/partials/room-card.blade.php +++ b/resources/views/meet/partials/room-card.blade.php @@ -13,6 +13,10 @@

{{ $room->title }}

@if ($room->isWebinar()) Webinar + @elseif ($room->isConference()) + Conference + @elseif ($room->isSpace()) + Room @endif {{ config('meet.room_statuses')[$room->status] ?? $room->status }} diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 388156f..0b5e1a0 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -46,7 +46,9 @@ data-caption-url="{{ route('meet.room.caption', $session) }}" data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}" data-mute-on-join="{{ $room->setting('mute_on_join', true) ? '1' : '0' }}" - data-video-off-on-join="{{ $room->setting('video_off_on_join', false) ? '1' : '0' }}" + data-video-off-on-join="{{ ($isAudioOnly ?? false) || $room->setting('video_off_on_join', false) ? '1' : '0' }}" + data-audio-only="{{ ($isAudioOnly ?? false) ? '1' : '0' }}" + data-is-space="{{ ($isSpace ?? false) ? '1' : '0' }}" data-watermark="{{ $watermark ? '1' : '0' }}" data-locked="{{ $session->is_locked ? '1' : '0' }}" data-recording-active="{{ $activeRecording ? '1' : '0' }}" @@ -80,6 +82,9 @@

{{ $room->title }}

+ @if ($isAudioOnly ?? false) +

Audio room

+ @endif

-
+
@@ -196,6 +201,7 @@ + +
+ diff --git a/resources/views/meet/spaces/index.blade.php b/resources/views/meet/spaces/index.blade.php new file mode 100644 index 0000000..b78e541 --- /dev/null +++ b/resources/views/meet/spaces/index.blade.php @@ -0,0 +1,22 @@ + +
+
+

Rooms

+

Audio-only discussions with a host, assigned speakers, and listeners — like X Spaces.

+
+ Create room +
+ +
+ @forelse ($spaces as $room) + @include('meet.partials.room-card', [ + 'room' => $room, + 'detailsRoute' => route('meet.spaces.show', $room), + ]) + @empty +

No rooms yet. Create one to host a private discussion.

+ @endforelse +
+ +
{{ $spaces->links() }}
+
diff --git a/resources/views/meet/spaces/show.blade.php b/resources/views/meet/spaces/show.blade.php new file mode 100644 index 0000000..7f10816 --- /dev/null +++ b/resources/views/meet/spaces/show.blade.php @@ -0,0 +1,76 @@ + +
+
+

Room

+

{{ $room->title }}

+

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

+
+ + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+ Host-led audio room. Assigned speakers can talk; everyone else listens and can request to speak. + @if ($room->setting('invite_only')) + This room is private — share the join link only with invited people. + @endif +
+ +
+

Join link

+
+ + +
+ +
+ @if ($room->status === 'live' && $room->activeSession()) + Join room + @elseif ($room->canRestart()) +
+ @csrf + +
+ @endif + QR code + Manage invitations +
+ + @if ($speakers->isNotEmpty()) +
+

Assigned speakers

+
    + @foreach ($speakers as $speaker) +
  • {{ $speaker->user_ref }}
  • + @endforeach +
+
+ @endif + + @if ($room->passcode) +

Passcode: {{ $room->passcode }}

+ @endif + + @if ($room->description) +

{{ $room->description }}

+ @endif +
+ + @if ($room->sessions->isNotEmpty()) +
+

Past sessions

+
    + @foreach ($room->sessions as $session) +
  • + {{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }} + {{ $session->peak_participants }} joined +
  • + @endforeach +
+
+ @endif +
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index a08fb9a..f5615cc 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -13,8 +13,12 @@ $nav = [ ['name' => 'Dashboard', 'route' => route('meet.dashboard'), 'active' => request()->routeIs('meet.dashboard'), 'icon' => ''], - ['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*') && ! request()->routeIs('meet.webinar.*'), + ['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*') && ! request()->routeIs('meet.webinar.*') && ! request()->routeIs('meet.spaces.*') && ! request()->routeIs('meet.conferences.*'), 'icon' => ''], + ['name' => 'Rooms', 'route' => route('meet.spaces.index'), 'active' => request()->routeIs('meet.spaces.*'), + 'icon' => ''], + ['name' => 'Conferences', 'route' => route('meet.conferences.index'), 'active' => request()->routeIs('meet.conferences.*'), + 'icon' => ''], ['name' => 'Webinars', 'route' => route('meet.webinars.index'), 'active' => request()->routeIs('meet.webinars.*') || request()->routeIs('meet.webinar.*'), 'icon' => ''], ['name' => 'Recordings', 'route' => route('meet.recordings.index'), 'active' => request()->routeIs('meet.recordings.*'), diff --git a/routes/web.php b/routes/web.php index 520c8c2..d2d892b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\Meet\AdminController; use App\Http\Controllers\Meet\AiController; use App\Http\Controllers\Meet\CalendarController; +use App\Http\Controllers\Meet\ConferenceController; use App\Http\Controllers\Meet\ChannelController; use App\Http\Controllers\Meet\ContactGroupController; use App\Http\Controllers\Meet\DashboardController; @@ -18,6 +19,7 @@ use App\Http\Controllers\Meet\OnboardingController; use App\Http\Controllers\Meet\RecordingController; use App\Http\Controllers\Meet\ReportController; use App\Http\Controllers\Meet\RoomController; +use App\Http\Controllers\Meet\SpaceController; use App\Http\Controllers\Meet\SettingsController; use App\Http\Controllers\Meet\SummaryController; use App\Http\Controllers\Meet\TemplateController; @@ -131,6 +133,18 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/webinars/{room}', [WebinarController::class, 'show'])->name('meet.webinars.show'); Route::post('/webinars/{room}/start', [WebinarController::class, 'start'])->name('meet.webinars.start'); + Route::get('/conferences', [ConferenceController::class, 'index'])->name('meet.conferences.index'); + Route::get('/conferences/create', [ConferenceController::class, 'create'])->name('meet.conferences.create'); + Route::post('/conferences', [ConferenceController::class, 'store'])->name('meet.conferences.store'); + Route::get('/conferences/{room}', [ConferenceController::class, 'show'])->name('meet.conferences.show'); + Route::post('/conferences/{room}/start', [ConferenceController::class, 'start'])->name('meet.conferences.start'); + + Route::get('/rooms', [SpaceController::class, 'index'])->name('meet.spaces.index'); + Route::get('/rooms/create', [SpaceController::class, 'create'])->name('meet.spaces.create'); + Route::post('/rooms', [SpaceController::class, 'store'])->name('meet.spaces.store'); + Route::get('/rooms/{room}', [SpaceController::class, 'show'])->name('meet.spaces.show'); + Route::post('/rooms/{room}/start', [SpaceController::class, 'start'])->name('meet.spaces.start'); + Route::get('/meetings/{room}/webinar/registrations', [WebinarRegistrationController::class, 'manage'])->name('meet.webinar.registrations'); Route::post('/meetings/{room}/webinar/registrations/{registration}/approve', [WebinarRegistrationController::class, 'approve'])->name('meet.webinar.registrations.approve'); Route::post('/meetings/{room}/webinar/registrations/{registration}/reject', [WebinarRegistrationController::class, 'reject'])->name('meet.webinar.registrations.reject'); diff --git a/tests/Feature/MeetWebTest.php b/tests/Feature/MeetWebTest.php index 91098ef..ac9f396 100644 --- a/tests/Feature/MeetWebTest.php +++ b/tests/Feature/MeetWebTest.php @@ -598,12 +598,11 @@ class MeetWebTest extends TestCase ]); } - public function test_host_can_create_town_hall_room(): void + public function test_host_can_create_conference(): void { $this->actingAs($this->user) - ->post('/meetings', [ + ->post('/conferences', [ 'title' => 'All hands', - 'type' => 'town_hall', 'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'), 'duration_minutes' => 90, ]) @@ -615,6 +614,21 @@ class MeetWebTest extends TestCase ]); } + public function test_host_can_create_space_room(): void + { + $this->actingAs($this->user) + ->post('/rooms', [ + 'title' => 'Leadership space', + 'invite_only' => true, + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('meet_rooms', [ + 'title' => 'Leadership space', + 'type' => 'space', + ]); + } + protected function createRoom(array $overrides = []): Room { return Room::create(array_merge([