Fix live room header flash and instant sidebar panel switching.
Deploy Ladill Meet / deploy (push) Successful in 1m21s

Server-render the speaker pill on load, hide noisy connection status on mobile, and use a single sidebarPanel slot so desktop panels replace in place instead of animating side by side.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-02 06:39:32 +00:00
co-authored by Cursor
parent 17dd7107ee
commit bbf984bc60
5 changed files with 210 additions and 93 deletions
@@ -63,4 +63,83 @@ class ParticipantPresenter
return null;
}
/**
* @param list<array<string, mixed>> $participants
* @param list<string> $presenterRefs
*/
public static function isSpeakerPerson(array $person, array $presenterRefs = []): bool
{
if (in_array($person['role'] ?? '', ['host', 'co_host', 'panelist'], true)) {
return true;
}
$userRef = $person['user_ref'] ?? null;
return is_string($userRef) && $userRef !== '' && in_array($userRef, $presenterRefs, true);
}
/**
* @param list<array<string, mixed>> $participants
* @param list<string> $presenterRefs
*/
public static function headerCountLabel(array $participants, bool $usesStageLayout, array $presenterRefs = []): string
{
$joined = array_values(array_filter($participants, fn ($p) => ($p['status'] ?? '') === 'joined'));
if ($usesStageLayout) {
$count = count(array_filter($joined, fn ($p) => self::isSpeakerPerson($p, $presenterRefs)));
return $count === 1 ? '1 speaker' : "{$count} speakers";
}
$count = count($joined);
return "{$count} in call";
}
/**
* @param list<array<string, mixed>> $participants
* @return array{display_name: string, avatar_url: ?string}|null
*/
public static function headerHost(array $participants, array $roomHost): ?array
{
foreach ($participants as $person) {
if (($person['status'] ?? '') !== 'joined') {
continue;
}
if (in_array($person['role'] ?? '', ['host', 'co_host'], true)) {
return [
'display_name' => (string) ($person['display_name'] ?? 'Host'),
'avatar_url' => $person['avatar_url'] ?? null,
];
}
}
if (($roomHost['display_name'] ?? '') !== '') {
return [
'display_name' => (string) $roomHost['display_name'],
'avatar_url' => $roomHost['avatar_url'] ?? null,
];
}
return null;
}
public static function initials(?string $name): string
{
$parts = preg_split('/\s+/', trim($name ?? '')) ?: [];
$parts = array_values(array_filter($parts, fn ($part) => $part !== ''));
if ($parts === []) {
return '?';
}
if (count($parts) === 1) {
return strtoupper(substr($parts[0], 0, 2));
}
return strtoupper(substr($parts[0], 0, 1).substr($parts[count($parts) - 1], 0, 1));
}
}
+23
View File
@@ -361,6 +361,29 @@ html.meet-room-page {
}
@media (min-width: 640px) {
.meet-room-page .meet-room-panels-stack {
position: relative;
height: 100%;
flex-shrink: 0;
transition: width 0s;
}
.meet-room-page .meet-room-panels-stack.is-open {
width: 20rem;
}
.meet-room-page .meet-room-panels-stack:not(.is-open) {
width: 0;
overflow: hidden;
}
.meet-room-page .meet-room-panels-stack .meet-room-panel__pane {
position: absolute;
inset: 0;
width: 20rem;
height: 100%;
}
.meet-room-page .meet-room-panel {
position: relative;
inset: auto;
+37 -39
View File
@@ -73,12 +73,9 @@ function meetRoom() {
assignedBreakoutRoomId: null,
breakoutRoomName: '',
breakoutClosesAt: '',
featuresOpen: false,
programmeOpen: false,
sidebarPanel: null,
whiteboardOpen: false,
qaInput: '',
chatOpen: false,
participantsOpen: false,
chatInput: '',
shareOpen: false,
shareCopied: false,
@@ -1074,6 +1071,16 @@ function meetRoom() {
return this.inCallParticipants().length;
},
headerParticipantLabel() {
if (this.usesStageLayout) {
const count = this.speakerCount();
return count === 1 ? '1 speaker' : `${count} speakers`;
}
return `${this.participantCount()} in call`;
},
inCallParticipants() {
return this.sessionParticipants.filter((p) => p.status === 'joined');
},
@@ -1518,7 +1525,7 @@ function meetRoom() {
if (!this.isHost || !this.config.breakoutsCreateUrl) return;
this.breakoutCount = '4';
this.breakoutModalOpen = true;
this.featuresOpen = false;
this.closeSidebar();
},
async submitBreakouts() {
@@ -1545,7 +1552,7 @@ function meetRoom() {
if (!this.isHost || !this.config.breakoutsCloseUrl) return;
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
this.featuresOpen = false;
this.closeSidebar();
this.breakoutsActive = false;
this.activeBreakouts = [];
},
@@ -1665,28 +1672,22 @@ function meetRoom() {
},
toggleProgramme() {
this.programmeOpen = !this.programmeOpen;
if (this.programmeOpen) {
this.chatOpen = false;
this.participantsOpen = false;
this.featuresOpen = false;
this.shareOpen = false;
}
this.toggleSidebarPanel('programme');
},
openUploadPicker() {
document.getElementById('meet-file-input')?.click();
this.featuresOpen = false;
this.closeSidebar();
},
async toggleRecordingFromMenu() {
await this.toggleRecording();
this.featuresOpen = false;
this.closeSidebar();
},
async toggleLockFromMenu() {
await this.toggleLock();
this.featuresOpen = false;
this.closeSidebar();
},
openEndMeetingConfirm() {
@@ -1941,34 +1942,33 @@ function meetRoom() {
this.applyStageLayout();
},
toggleChat() {
this.chatOpen = !this.chatOpen;
if (this.chatOpen) {
this.participantsOpen = false;
this.featuresOpen = false;
this.programmeOpen = false;
closeSidebar() {
this.sidebarPanel = null;
},
toggleSidebarPanel(name) {
this.sidebarPanel = this.sidebarPanel === name ? null : name;
if (this.sidebarPanel) {
this.shareOpen = false;
}
},
toggleChat() {
this.toggleSidebarPanel('chat');
},
toggleParticipants() {
this.participantsOpen = !this.participantsOpen;
if (this.participantsOpen) {
this.chatOpen = false;
this.featuresOpen = false;
this.programmeOpen = false;
this.shareOpen = false;
}
this.toggleSidebarPanel('participants');
},
toggleFeatures() {
this.featuresOpen = !this.featuresOpen;
if (this.featuresOpen) {
this.chatOpen = false;
this.participantsOpen = false;
this.programmeOpen = false;
this.toggleSidebarPanel('features');
},
openParticipantsPanel() {
this.sidebarPanel = 'participants';
this.shareOpen = false;
}
},
async openShare() {
@@ -1992,9 +1992,7 @@ function meetRoom() {
this.shareOpen = true;
this.shareCopied = false;
this.participantsOpen = false;
this.chatOpen = false;
this.featuresOpen = false;
this.closeSidebar();
},
async copyJoinLink() {
@@ -2067,7 +2065,7 @@ function meetRoom() {
const previous = this.waitingCount;
this.waitingCount = data.waiting_count;
if (this.isHost && data.waiting_count > previous) {
this.participantsOpen = true;
this.openParticipantsPanel();
}
}
this.updateChatFromPoll(data.messages || []);
@@ -1,30 +1,13 @@
@props([
'show',
'title' => null,
'close' => 'closeSidebar()',
])
<aside x-show="{{ $show }}" x-cloak
class="meet-room-panel"
@keydown.escape.window="{{ $show }} = false">
<div x-show="{{ $show }}"
x-transition:enter="transition-opacity ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="meet-room-panel__backdrop"
@click="{{ $show }} = false"
aria-hidden="true"></div>
<div x-show="{{ $show }}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full sm:translate-y-0"
x-transition:enter-end="translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-full sm:translate-y-0"
class="meet-room-panel__sheet">
class="meet-room-panel meet-room-panel__pane"
@keydown.escape.window="{{ $close }}">
<div class="meet-room-panel__sheet">
<div class="meet-room-panel__handle" aria-hidden="true"><span></span></div>
<div class="meet-room-panel__header">
@@ -38,7 +21,7 @@
<div class="text-xs text-slate-400">{{ $subtitle }}</div>
@endisset
</div>
<button @click="{{ $show }} = false" type="button"
<button @click="{{ $close }}" type="button"
class="shrink-0 rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
title="Close">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
+63 -29
View File
@@ -6,6 +6,7 @@
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $room->title }} · Ladill Meet</title>
@include('partials.favicon')
<style>[x-cloak]{display:none!important}</style>
@vite(['resources/css/app.css', 'resources/js/meet-room.js'])
</head>
<body class="h-full overflow-hidden bg-slate-950 font-sans text-white" x-data="meetRoom()" x-init="init()">
@@ -31,6 +32,29 @@
default => 'meeting',
};
$sessionNounTitle = ucfirst($sessionNoun);
$presenterRefsList = (array) ($presenterRefs ?? []);
$headerHostPerson = ParticipantPresenter::headerHost($joinedParticipants->all(), $roomHost);
$headerParticipantLabel = ParticipantPresenter::headerCountLabel(
$joinedParticipants->all(),
(bool) ($usesStageLayout ?? false),
$presenterRefsList,
);
$headerWaitingCount = $joinedParticipants->where('status', 'waiting')->count();
$stageLayoutName = ($stageConfig['stage_layout'] ?? 'plenary') === 'panel' ? 'Panel discussion' : 'Plenary';
$initialConferenceStatus = match (true) {
($inBreakout ?? false) => 'Breakout · meeting mode',
($isGreenRoom ?? false) && ($usesStageLayout ?? false) => "Green room · {$stageLayoutName}",
($isGreenRoom ?? false) => 'Green room',
! ($usesStageLayout ?? false) => 'Conference live',
($stageConfig['stage_layout'] ?? 'plenary') === 'panel' => 'Panel discussion live',
default => 'Plenary live',
};
$initialConferenceStatusClass = match (true) {
($inBreakout ?? false) => 'text-amber-400/90',
($isGreenRoom ?? false) => 'text-violet-400/90',
($stageConfig['stage_layout'] ?? 'plenary') === 'panel' => 'text-violet-400/90',
default => 'text-sky-400/90',
};
@endphp
<div id="meet-config"
data-token="{{ $mediaToken }}"
@@ -111,15 +135,15 @@
@if ($isAudioOnly ?? false)
<p class="text-[10px] font-medium uppercase tracking-wide text-emerald-400/90">Audio room</p>
@elseif ($isConference ?? false)
<p class="text-[10px] font-medium uppercase tracking-wide"
<p class="text-[10px] font-medium uppercase tracking-wide {{ $initialConferenceStatusClass }}"
:class="conferenceStatusClass()"
x-text="conferenceStatusLabel()"></p>
x-text="conferenceStatusLabel()">{{ $initialConferenceStatus }}</p>
@endif
<p class="text-xs text-slate-400" x-text="connectionStatus"></p>
<p class="min-h-4 truncate text-xs font-medium leading-4"
<p class="hidden text-xs text-slate-400 sm:block" x-text="connectionStatus">Connecting…</p>
<p class="hidden min-h-4 truncate text-xs font-medium leading-4 text-slate-600 sm:block"
:class="activeSpeakerCount() > 0 ? 'text-emerald-400' : 'text-slate-600'"
:title="activeSpeakersMessage()"
x-text="activeSpeakersMessage() || '...'"></p>
x-text="activeSpeakersMessage()">&nbsp;</p>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-slate-400">
<button @click.stop="openShare()" data-share-trigger type="button"
@@ -134,20 +158,22 @@
class="relative inline-flex items-center gap-2 rounded-lg bg-slate-900 px-2 py-1 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
:class="isHost && waitingCount > 0 ? 'ring-2 ring-amber-400/60' : ''"
:title="usesStageLayout ? 'View speakers' : 'View participants'">
<span x-show="isHost && waitingCount > 0"
<span x-show="isHost && waitingCount > 0" x-cloak
x-text="waitingCount > 9 ? '9+' : waitingCount"
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-amber-950"></span>
<span x-show="hostParticipant()" class="relative flex h-7 w-7 shrink-0">
<img x-show="hostParticipant()?.avatar_url"
:src="hostParticipant()?.avatar_url"
alt=""
@if ($headerHostPerson)
<span class="relative flex h-7 w-7 shrink-0 overflow-hidden rounded-full" aria-hidden="true">
@if (! empty($headerHostPerson['avatar_url']))
<img src="{{ $headerHostPerson['avatar_url'] }}" alt="" width="28" height="28"
class="h-7 w-7 rounded-full object-cover">
<span x-show="hostParticipant() && !hostParticipant()?.avatar_url"
class="flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold text-white"
:style="avatarStyle(hostParticipant()?.display_name)"
x-text="participantInitials(hostParticipant()?.display_name || '')"></span>
@else
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-slate-700 text-[11px] font-semibold text-white">
{{ ParticipantPresenter::initials($headerHostPerson['display_name']) }}
</span>
<span x-text="usesStageLayout ? speakerCount() + (speakerCount() === 1 ? ' speaker' : ' speakers') : participantCount() + ' in call'"></span>
@endif
</span>
@endif
<span x-text="headerParticipantLabel()">{{ $headerParticipantLabel }}</span>
</button>
</div>
</header>
@@ -228,7 +254,7 @@
<span class="truncate"
x-text="waitingCount === 1 ? '1 person is waiting to be admitted' : waitingCount + ' people are waiting to be admitted'"></span>
</div>
<button @click="participantsOpen = true" type="button"
<button @click="openParticipantsPanel()" type="button"
class="shrink-0 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-amber-950 transition-colors hover:bg-amber-400">
Review
</button>
@@ -339,7 +365,7 @@
</button>
<button @click="toggleFeatures()" type="button"
class="rounded-full p-3 transition-colors sm:hidden"
:class="featuresOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
:class="sidebarPanel === 'features' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="More options">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
</button>
@@ -356,14 +382,14 @@
</button>
<button @click="toggleChat()" type="button"
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
:class="chatOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
:class="sidebarPanel === 'chat' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="Chat">
@include('meet.room.partials.meet-icon', ['icon' => 'chat'])
</button>
<input type="file" id="meet-file-input" class="hidden" @change="uploadFile">
<button @click="toggleFeatures()" type="button"
class="hidden items-center gap-1.5 rounded-full px-3.5 py-3 text-xs font-medium transition-colors sm:inline-flex"
:class="featuresOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
:class="sidebarPanel === 'features' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="More options">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
More
@@ -441,7 +467,14 @@
</div>
</div>
<x-meet.room.panel show="chatOpen" title="{{ $sessionNounTitle }} chat">
<div class="meet-room-panels-stack max-sm:contents"
:class="sidebarPanel ? 'is-open' : ''">
<div x-show="sidebarPanel" x-cloak
class="meet-room-panel__backdrop fixed inset-0 z-40 sm:hidden"
@click="closeSidebar()"
aria-hidden="true"></div>
<x-meet.room.panel show="sidebarPanel === 'chat'" title="{{ $sessionNounTitle }} chat">
<x-slot:subtitle>
<p>Messages are visible to everyone in the call</p>
</x-slot:subtitle>
@@ -473,7 +506,7 @@
</div>
</x-meet.room.panel>
<x-meet.room.panel show="programmeOpen" title="Programme">
<x-meet.room.panel show="sidebarPanel === 'programme'" title="Programme">
<x-slot:subtitle>
<p>Lineup from Events when linked to this {{ $sessionNoun }}</p>
</x-slot:subtitle>
@@ -487,7 +520,7 @@
</div>
</x-meet.room.panel>
<x-meet.room.panel show="participantsOpen" :title="null">
<x-meet.room.panel show="sidebarPanel === 'participants'" :title="null">
<x-slot:subtitle>
<template x-if="usesStageLayout">
<div>
@@ -670,7 +703,7 @@
</div>
</div>
<x-meet.room.panel show="featuresOpen" title="More options">
<x-meet.room.panel show="sidebarPanel === 'features'" title="More options">
<x-slot:subtitle>
<p>{{ $sessionNounTitle }} tools and extras</p>
</x-slot:subtitle>
@@ -679,23 +712,23 @@
<div class="space-y-1">
<div class="space-y-1 sm:hidden" x-show="canPublish">
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Let the host know you have a question"
@click="raiseHand(); featuresOpen = false" />
@click="raiseHand(); closeSidebar()" />
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send applause — sound plays when others join in"
@click="sendReaction('thumbs'); featuresOpen = false" />
@click="sendReaction('thumbs'); closeSidebar()" />
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
@click="sendReaction('heart'); featuresOpen = false" />
@click="sendReaction('heart'); closeSidebar()" />
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
@click="toggleChat(); featuresOpen = false" />
@click="toggleChat()" />
<div class="my-1 border-t border-slate-800"></div>
</div>
<div class="space-y-1 sm:hidden" x-show="!canPublish">
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
@click="toggleChat(); featuresOpen = false" />
@click="toggleChat()" />
<div class="my-1 border-t border-slate-800"></div>
</div>
<x-meet.room.menu-item icon="programme" title="Programme" :subtitle="'View the '.$sessionNoun.' lineup from Events'"
@click="toggleProgramme(); featuresOpen = false" />
@click="toggleProgramme()" />
<button @click="openUploadPicker()" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
@@ -772,5 +805,6 @@
</div>
</x-meet.room.panel>
</div>
</div>
</body>
</html>