Restyle audio room speaker cards like Spaces and add room chat to the toolbar.
Deploy Ladill Meet / deploy (push) Successful in 49s

Use a three-column avatar grid with role labels and speaking/muted indicators, enable chat in audio rooms, and swap the mobile People nav button for Chat since People stays in the header.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-04 01:10:12 +00:00
co-authored by Cursor
parent 0e3e940ad4
commit 777a3e9126
6 changed files with 150 additions and 28 deletions
+1 -2
View File
@@ -51,7 +51,7 @@ class SpaceService
/** /**
* Whether a live-room feature is available for this room type. * Whether a live-room feature is available for this room type.
* Audio rooms (Spaces) mirror Twitter Spaces no breakouts, chat, Q&A, etc. * Audio rooms (Spaces) mirror Twitter Spaces no breakouts, Q&A, etc.
*/ */
public function allowsRoomFeature(Room $room, string $feature): bool public function allowsRoomFeature(Room $room, string $feature): bool
{ {
@@ -61,7 +61,6 @@ class SpaceService
return ! in_array($feature, [ return ! in_array($feature, [
'breakouts', 'breakouts',
'chat',
'qa', 'qa',
'polls', 'polls',
'files', 'files',
+64
View File
@@ -370,6 +370,70 @@ html.meet-room-page {
isolation: isolate; isolation: isolate;
} }
.meet-room-page .meet-audio-grid.meet-space-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.25rem 0.75rem;
padding: 1rem 0.75rem 1.5rem;
max-width: 24rem;
margin-inline: auto;
align-content: start;
}
@media (min-width: 640px) {
.meet-room-page .meet-audio-grid.meet-space-grid {
max-width: 28rem;
gap: 1.5rem 1rem;
}
}
.meet-room-page .meet-audio-grid.meet-space-grid [data-participant-tile] {
max-width: none;
}
.meet-room-page .meet-space-tile {
background: transparent;
box-shadow: none;
}
.meet-room-page .meet-space-tile.meet-tile-speaking {
box-shadow: none;
}
.meet-room-page .meet-space-tile.meet-tile-speaking [data-tile-avatar-circle] {
box-shadow: 0 0 0 2px rgb(56 189 248 / 0.85);
}
.meet-room-page .meet-space-tile [data-tile-name] {
width: 100%;
padding-inline: 0.125rem;
}
.meet-room-page .meet-space-tile [data-tile-role-row] {
min-height: 1rem;
max-width: 100%;
}
.meet-room-page .meet-space-tile [data-tile-role] {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meet-room-page .meet-space-tile [data-tile-avatar-circle] {
height: 4.75rem;
width: 4.75rem;
font-size: 1.125rem;
}
@media (min-width: 640px) {
.meet-room-page .meet-space-tile [data-tile-avatar-circle] {
height: 5.25rem;
width: 5.25rem;
font-size: 1.25rem;
}
}
.meet-room-page .meet-audio-tile { .meet-room-page .meet-audio-tile {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
+62 -13
View File
@@ -711,7 +711,10 @@ function meetRoom() {
const displayName = this.displayNameForParticipant(participant); const displayName = this.displayNameForParticipant(participant);
const avatarUrl = this.avatarUrlForParticipant(participant); const avatarUrl = this.avatarUrlForParticipant(participant);
const isAudioTile = tile.classList.contains('meet-audio-tile'); const isAudioTile = tile.classList.contains('meet-audio-tile');
const sizeClass = isAudioTile ? 'h-[4.5rem] w-[4.5rem] text-xl' : 'h-20 w-20 text-2xl'; const isSpaceTile = tile.classList.contains('meet-space-tile');
const sizeClass = isSpaceTile
? 'h-[4.75rem] w-[4.75rem] text-lg sm:h-[5.25rem] sm:w-[5.25rem] sm:text-xl'
: isAudioTile ? 'h-[4.5rem] w-[4.5rem] text-xl' : 'h-20 w-20 text-2xl';
circle.replaceChildren(); circle.replaceChildren();
@@ -989,6 +992,35 @@ function meetRoom() {
return room.remoteParticipants.get(identity) || null; return room.remoteParticipants.get(identity) || null;
}, },
spaceRoleLabel(role) {
return {
host: 'Host',
co_host: 'Co-host',
panelist: 'Speaker',
}[role] || '';
},
spaceSpeakerTileShellHtml() {
return `
<div class="flex w-full justify-center">
<div data-tile-avatar-circle class="flex h-[4.75rem] w-[4.75rem] shrink-0 items-center justify-center rounded-full text-lg font-semibold text-white"></div>
</div>
<span data-tile-name class="mt-2 max-w-full truncate text-center text-sm font-bold leading-tight text-white"></span>
<div data-tile-role-row class="mt-1 flex max-w-full items-center justify-center gap-1">
<span data-tile-speaking class="hidden shrink-0 text-sky-400" title="Speaking">
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect class="meet-speaking-bar meet-speaking-bar--1" x="1" y="5" width="2.5" height="6" rx="1"/>
<rect class="meet-speaking-bar meet-speaking-bar--2" x="6.75" y="3" width="2.5" height="10" rx="1"/>
<rect class="meet-speaking-bar meet-speaking-bar--3" x="12.5" y="6" width="2.5" height="4" rx="1"/>
</svg>
</span>
<span data-tile-mic class="hidden shrink-0 text-slate-500" title="Muted">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</span>
<span data-tile-role class="truncate text-xs text-slate-400"></span>
</div>`;
},
participantTileShellHtml() { participantTileShellHtml() {
return ` return `
<div data-tile-body class="relative min-h-0 flex-1"> <div data-tile-body class="relative min-h-0 flex-1">
@@ -1019,14 +1051,26 @@ function meetRoom() {
ensureParticipantTileShell(identity) { ensureParticipantTileShell(identity) {
let tile = this.findParticipantTile(identity); let tile = this.findParticipantTile(identity);
const wantsSpaceTile = this.isSpace && !this.usesStageLayout;
if (tile && wantsSpaceTile !== tile.classList.contains('meet-space-tile')) {
tile.remove();
tile = null;
}
if (!tile) { if (!tile) {
tile = document.createElement('div'); tile = document.createElement('div');
tile.dataset.participantTile = identity; tile.dataset.participantTile = identity;
tile.className = this.audioOnly
? 'meet-audio-tile relative flex aspect-square w-full flex-col overflow-hidden rounded-2xl bg-slate-900' if (wantsSpaceTile) {
: 'relative flex aspect-video w-full flex-col overflow-hidden rounded-xl bg-slate-900 min-h-0'; tile.className = 'meet-space-tile flex w-full min-w-0 flex-col items-center px-1 py-1';
tile.innerHTML = this.participantTileShellHtml(); tile.innerHTML = this.spaceSpeakerTileShellHtml();
} else {
tile.className = this.audioOnly
? 'meet-audio-tile relative flex aspect-square w-full flex-col overflow-hidden rounded-2xl bg-slate-900'
: 'relative flex aspect-video w-full flex-col overflow-hidden rounded-xl bg-slate-900 min-h-0';
tile.innerHTML = this.participantTileShellHtml();
}
} }
return tile; return tile;
@@ -1040,15 +1084,24 @@ function meetRoom() {
const pseudoParticipant = { identity: person.uuid, name: person.display_name }; const pseudoParticipant = { identity: person.uuid, name: person.display_name };
this.updateTileAvatarCircle(tile, pseudoParticipant); this.updateTileAvatarCircle(tile, pseudoParticipant);
tile.querySelector('[data-tile-name]').textContent = person.display_name;
const speaking = this.isSpeaking(person.uuid); const speaking = this.isSpeaking(person.uuid);
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
const muted = liveParticipant const muted = liveParticipant
? !liveParticipant.isMicrophoneEnabled ? !liveParticipant.isMicrophoneEnabled
: Boolean(person.is_muted); : Boolean(person.is_muted);
if (tile.classList.contains('meet-space-tile')) {
tile.querySelector('[data-tile-name]').textContent = person.display_name;
tile.querySelector('[data-tile-role]').textContent = this.spaceRoleLabel(person.role);
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
tile.querySelector('[data-tile-mic]')?.classList.toggle('hidden', !muted || speaking);
return;
}
tile.querySelector('[data-tile-name]').textContent = person.display_name;
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
tile.querySelector('[data-tile-mic]')?.classList.toggle('hidden', !muted); tile.querySelector('[data-tile-mic]')?.classList.toggle('hidden', !muted);
tile.querySelector('[data-tile-cam]')?.classList.add('hidden'); tile.querySelector('[data-tile-cam]')?.classList.add('hidden');
}, },
@@ -2755,10 +2808,6 @@ function meetRoom() {
}, },
toggleChat() { toggleChat() {
if (this.isSpace) {
return;
}
this.toggleSidebarPanel('chat'); this.toggleSidebarPanel('chat');
}, },
+17 -10
View File
@@ -75,7 +75,7 @@
data-is-host="{{ ($canManageConference ?? $participant->isHost()) ? '1' : '0' }}" data-is-host="{{ ($canManageConference ?? $participant->isHost()) ? '1' : '0' }}"
data-poll-url="{{ route('meet.room.poll', $session) }}" data-poll-url="{{ route('meet.room.poll', $session) }}"
data-ended-url="{{ route('meet.ended', $session) }}" data-ended-url="{{ route('meet.ended', $session) }}"
data-chat-url="{{ ($isSpace ?? false) ? '' : route('meet.room.chat', $session) }}" data-chat-url="{{ route('meet.room.chat', $session) }}"
data-reaction-url="{{ route('meet.room.reaction', $session) }}" data-reaction-url="{{ route('meet.room.reaction', $session) }}"
data-raise-hand-url="{{ route('meet.room.raise-hand', $session) }}" data-raise-hand-url="{{ route('meet.room.raise-hand', $session) }}"
data-speak-request-url="{{ ($usesSpeakAccess ?? false) ? route('meet.room.speak.request', $session) : '' }}" data-speak-request-url="{{ ($usesSpeakAccess ?? false) ? route('meet.room.speak.request', $session) : '' }}"
@@ -339,7 +339,7 @@
<main class="relative flex min-h-0 min-w-0 flex-1 flex-col p-4 pb-2 meet-room-main"> <main class="relative flex min-h-0 min-w-0 flex-1 flex-col p-4 pb-2 meet-room-main">
<div id="meet-stage" class="flex h-full min-h-0 flex-1 flex-col gap-3" :class="usesStageLayout ? 'meet-stage meet-stage--' + stageLayout : ''"> <div id="meet-stage" class="flex h-full min-h-0 flex-1 flex-col gap-3" :class="usesStageLayout ? 'meet-stage meet-stage--' + stageLayout : ''">
<div id="meet-stage-spotlight" class="meet-stage-spotlight relative min-h-0 flex-1 overflow-hidden rounded-xl bg-slate-900"></div> <div id="meet-stage-spotlight" class="meet-stage-spotlight relative min-h-0 flex-1 overflow-hidden rounded-xl bg-slate-900"></div>
<div id="video-grid" class="min-h-0 min-w-0" :class="audioOnly ? 'meet-audio-grid flex-1' : (usesStageLayout ? 'meet-stage-panel-grid flex-1 h-full' : 'meet-gallery-grid h-full')"></div> <div id="video-grid" class="min-h-0 min-w-0" :class="audioOnly ? ((isSpace ? 'meet-audio-grid meet-space-grid flex-1' : 'meet-audio-grid flex-1')) : (usesStageLayout ? 'meet-stage-panel-grid flex-1 h-full' : 'meet-gallery-grid h-full')"></div>
<div id="meet-audience-strip" class="meet-audience-strip shrink-0"></div> <div id="meet-audience-strip" class="meet-audience-strip shrink-0"></div>
</div> </div>
<div id="screen-share" class="absolute inset-4 hidden rounded-xl bg-black"></div> <div id="screen-share" class="absolute inset-4 hidden rounded-xl bg-black"></div>
@@ -450,12 +450,12 @@
@include('meet.room.partials.meet-icon', ['icon' => 'react']) @include('meet.room.partials.meet-icon', ['icon' => 'react'])
</button> </button>
@if ($isSpace ?? false) @if ($isSpace ?? false)
<button @click="toggleParticipants()" type="button" <button @click="toggleChat()" type="button"
data-alpine-toolbar data-alpine-toolbar
class="rounded-full p-3 transition-colors sm:hidden" class="rounded-full p-3 transition-colors sm:hidden"
:class="sidebarPanel === 'participants' ? '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="People"> title="Chat">
<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="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></svg> @include('meet.room.partials.meet-icon', ['icon' => 'chat'])
</button> </button>
@if ($participant->isHost()) @if ($participant->isHost())
<button @click="toggleRecording()" type="button" <button @click="toggleRecording()" type="button"
@@ -504,6 +504,15 @@
More More
</button> </button>
@endunless @endunless
@if ($isSpace ?? false)
<button @click="toggleChat()" type="button"
data-alpine-toolbar
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
: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>
@endif
@if (($isSpace ?? false) && $participant->isHost()) @if (($isSpace ?? false) && $participant->isHost())
<button @click="toggleRecording()" type="button" <button @click="toggleRecording()" type="button"
data-alpine-toolbar data-alpine-toolbar
@@ -595,10 +604,9 @@
@click="closeSidebar()" @click="closeSidebar()"
aria-hidden="true"></div> aria-hidden="true"></div>
@unless ($isSpace ?? false) <x-meet.room.panel show="sidebarPanel === 'chat'" title="{{ ($isSpace ?? false) ? 'Room chat' : $sessionNounTitle.' chat' }}">
<x-meet.room.panel show="sidebarPanel === 'chat'" title="{{ $sessionNounTitle }} chat">
<x-slot:subtitle> <x-slot:subtitle>
<p>Messages are visible to everyone in the call</p> <p>Messages are visible to everyone in the {{ ($isSpace ?? false) ? 'room' : 'call' }}</p>
</x-slot:subtitle> </x-slot:subtitle>
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
@@ -627,7 +635,6 @@
</form> </form>
</div> </div>
</x-meet.room.panel> </x-meet.room.panel>
@endunless
@unless ($isSpace ?? false) @unless ($isSpace ?? false)
<x-meet.room.panel show="sidebarPanel === 'programme'" title="Programme"> <x-meet.room.panel show="sidebarPanel === 'programme'" title="Programme">
+2 -2
View File
@@ -1301,8 +1301,8 @@ class MeetWebTest extends TestCase
$response->assertOk(); $response->assertOk();
$response->assertSee('data-is-space="1"', false); $response->assertSee('data-is-space="1"', false);
$response->assertDontSee('title="Chat"', false); $response->assertSee('title="Chat"', false);
$response->assertDontSee('>More</', false); $response->assertDontSee('title="People"', false);
$response->assertDontSee('Start breakouts', false); $response->assertDontSee('Start breakouts', false);
$response->assertDontSee('Share files', false); $response->assertDontSee('Share files', false);
$response->assertDontSee('View the room lineup from Events', false); $response->assertDontSee('View the room lineup from Events', false);
+4 -1
View File
@@ -14,9 +14,12 @@ class SpaceServiceTest extends TestCase
$space = new Room(['type' => 'space']); $space = new Room(['type' => 'space']);
$meeting = new Room(['type' => 'meeting']); $meeting = new Room(['type' => 'meeting']);
foreach (['breakouts', 'chat', 'qa', 'polls', 'files', 'whiteboard', 'programme', 'live_stream', 'lock'] as $feature) { foreach (['breakouts', 'qa', 'polls', 'files', 'whiteboard', 'programme', 'live_stream', 'lock'] as $feature) {
$this->assertFalse($service->allowsRoomFeature($space, $feature), $feature); $this->assertFalse($service->allowsRoomFeature($space, $feature), $feature);
$this->assertTrue($service->allowsRoomFeature($meeting, $feature), $feature); $this->assertTrue($service->allowsRoomFeature($meeting, $feature), $feature);
} }
$this->assertTrue($service->allowsRoomFeature($space, 'chat'));
$this->assertTrue($service->allowsRoomFeature($meeting, 'chat'));
} }
} }