fix: voice room UI overlap, sizing, and scroll button issues (#371)

Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
Aditya Chaudhary 2026-08-27 23:06:10 +00:00 committed by hodlbod
parent fe9b0fe87a
commit 2974faa2e9
6 changed files with 305 additions and 190 deletions

View file

@ -286,6 +286,15 @@ export const callState = writable<CallState>(CallState.Disconnected)
export const callTargetRoom = writable<Room | undefined>(undefined) export const callTargetRoom = writable<Room | undefined>(undefined)
export const deriveIsCallActiveElsewhere = (url: string | undefined, h: string | undefined) =>
derived(
[callState, callTargetRoom],
([$state, $targetRoom]) =>
($state === CallState.Joining || $state === CallState.Connected) &&
$targetRoom !== undefined &&
!($targetRoom.url === url && $targetRoom.h === h),
)
export const speakingParticipants = writable<CallParticipant[]>([]) export const speakingParticipants = writable<CallParticipant[]>([])
export const participantMediaState = writable(new Map<string, ParticipantMediaState>()) export const participantMediaState = writable(new Map<string, ParticipantMediaState>())

View file

@ -18,6 +18,7 @@
callTargetRoom, callTargetRoom,
callMicMuted, callMicMuted,
cancelJoinVoiceRoom, cancelJoinVoiceRoom,
deriveIsCallActiveElsewhere,
leaveVoiceRoom, leaveVoiceRoom,
toggleMute, toggleMute,
} from "@app/call" } from "@app/call"
@ -27,19 +28,10 @@
// The call's own room page already shows full controls (CallControlBar), so // The call's own room page already shows full controls (CallControlBar), so
// the banner would just be redundant clutter there. // the banner would just be redundant clutter there.
const isViewingCurrentVoiceRoom = $derived( const isCallActiveElsewhere = $derived(
$callTargetRoom !== undefined && deriveIsCallActiveElsewhere(routeUrl, typeof h === "string" ? h : undefined),
routeUrl !== undefined &&
typeof h === "string" &&
$callTargetRoom.url === routeUrl &&
$callTargetRoom.h === h,
)
const visible = $derived(
($callState === CallState.Joining || $callState === CallState.Connected) &&
$callTargetRoom !== undefined &&
!isViewingCurrentVoiceRoom,
) )
const visible = $derived($isCallActiveElsewhere)
const roomName = $derived( const roomName = $derived(
$callTargetRoom ? displayRoom($callTargetRoom.url, $callTargetRoom.h) : "", $callTargetRoom ? displayRoom($callTargetRoom.url, $callTargetRoom.h) : "",

View file

@ -67,6 +67,16 @@
const chatUnread = $derived($notifications.has(roomPath)) const chatUnread = $derived($notifications.has(roomPath))
const isChatPanelActive = $derived($videoCallLayout === VideoCallLayout.Split) const isChatPanelActive = $derived($videoCallLayout === VideoCallLayout.Split)
// With chat open, the floating bar shares the video pane with the sidebar rather
// than spanning the full width — a smaller bar reads as sized for that narrower
// space instead of just looking unchanged. Sized below mobile's max-md:h-9 (36px)
// since desktop is mouse-driven, not a touch target. max-md sizing itself is
// unaffected: mobile chat is a full-screen overlay, not a space-sharing sidebar.
const compactButtonClass = $derived(
cx("max-md:h-9 max-md:w-9", isChatPanelActive && "md:h-8 md:w-8"),
)
const iconSize = $derived(isChatPanelActive ? 3.5 : 4.5)
const onChatToggle = () => { const onChatToggle = () => {
videoCallLayout.update(p => videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split, p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
@ -93,9 +103,9 @@
<Button <Button
data-tip="Cancel" data-tip="Cancel"
aria-label="Cancel joining voice room" aria-label="Cancel joining voice room"
class="button button-circle button-xs button-neutral" class="button button-circle button-sm button-neutral"
onclick={cancelJoinVoiceRoom}> onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={3} /> <Icon icon={CloseCircle} size={4} />
</Button> </Button>
</div> </div>
{/if} {/if}
@ -105,6 +115,7 @@
out:fade={{duration: 120}} out:fade={{duration: 120}}
class={cx( class={cx(
"pointer-events-auto col-start-1 row-start-1 flex items-center gap-1.5 rounded-full border border-line bg-surface/95 p-1.5 shadow-xl backdrop-blur-md md:gap-2 md:p-2", "pointer-events-auto col-start-1 row-start-1 flex items-center gap-1.5 rounded-full border border-line bg-surface/95 p-1.5 shadow-xl backdrop-blur-md md:gap-2 md:p-2",
isChatPanelActive && "md:gap-1 md:p-1",
hideConnectedOnDesktop && "md:hidden", hideConnectedOnDesktop && "md:hidden",
)}> )}>
<Button <Button
@ -112,11 +123,12 @@
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"} aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
aria-pressed={!$callMicMuted} aria-pressed={!$callMicMuted}
class={cx( class={cx(
"button button-circle max-md:h-9 max-md:w-9", "button button-circle",
compactButtonClass,
$callMicMuted ? "button-neutral" : "button-primary", $callMicMuted ? "button-neutral" : "button-primary",
)} )}
onclick={toggleMute}> onclick={toggleMute}>
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={4.5} /> <Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={iconSize} />
</Button> </Button>
{#if $currentCallSession} {#if $currentCallSession}
<Button <Button
@ -124,13 +136,14 @@
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"} aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
aria-pressed={$currentCallSession.cameraOn} aria-pressed={$currentCallSession.cameraOn}
class={cx( class={cx(
"button button-circle max-md:h-9 max-md:w-9", "button button-circle",
compactButtonClass,
$currentCallSession.cameraOn ? "button-primary" : "button-neutral", $currentCallSession.cameraOn ? "button-primary" : "button-neutral",
)} )}
onclick={toggleCamera}> onclick={toggleCamera}>
<Icon <Icon
icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff}
size={4.5} /> size={iconSize} />
</Button> </Button>
{#if !Capacitor.isNativePlatform()} {#if !Capacitor.isNativePlatform()}
<Button <Button
@ -140,31 +153,33 @@
: "Share screen"} : "Share screen"}
aria-pressed={$currentCallSession.screenShareOn} aria-pressed={$currentCallSession.screenShareOn}
class={cx( class={cx(
"button button-circle max-md:h-9 max-md:w-9", "button button-circle",
compactButtonClass,
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral", $currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
)} )}
onclick={toggleScreenShare}> onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4.5} /> <Icon icon={Monitor} size={iconSize} />
</Button> </Button>
{/if} {/if}
{/if} {/if}
<Button <Button
data-tip="Call settings" data-tip="Call settings"
aria-label="Call settings" aria-label="Call settings"
class="button button-circle button-neutral max-md:h-9 max-md:w-9" class={cx("button button-circle button-neutral", compactButtonClass)}
onclick={openCallSettings}> onclick={openCallSettings}>
<Icon icon={Settings} size={4.5} /> <Icon icon={Settings} size={iconSize} />
</Button> </Button>
<Button <Button
data-tip="Toggle chat" data-tip="Toggle chat"
aria-label="Toggle chat panel" aria-label="Toggle chat panel"
aria-pressed={isChatPanelActive} aria-pressed={isChatPanelActive}
class={cx( class={cx(
"button button-circle relative max-md:h-9 max-md:w-9", "button button-circle relative",
compactButtonClass,
isChatPanelActive ? "button-primary" : "button-neutral", isChatPanelActive ? "button-primary" : "button-neutral",
)} )}
onclick={onChatToggle}> onclick={onChatToggle}>
<Icon icon={ChatRound} size={4.5} /> <Icon icon={ChatRound} size={iconSize} />
{#if chatUnread} {#if chatUnread}
<span <span
class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface" class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface"
@ -175,9 +190,9 @@
<Button <Button
data-tip="Leave call" data-tip="Leave call"
aria-label="Leave voice room" aria-label="Leave voice room"
class="button button-circle button-error max-md:h-9 max-md:w-9" class={cx("button button-circle button-error", compactButtonClass)}
onclick={leaveVoiceRoom}> onclick={leaveVoiceRoom}>
<Icon icon={EndCall} size={5} /> <Icon icon={EndCall} size={isChatPanelActive ? 4 : 5} />
</Button> </Button>
</div> </div>
{/if} {/if}

View file

@ -32,7 +32,14 @@
import CallControlBar from "@app/components/CallControlBar.svelte" import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user} from "@app/core" import {deletes, relays, rooms, thunks, user} from "@app/core"
import {joinRoom, leaveRoom} from "@app/access" import {joinRoom, leaveRoom} from "@app/access"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call" import {
CallState,
callTargetRoom,
callState,
VideoCallLayout,
videoCallLayout,
deriveIsCallActiveElsewhere,
} from "@app/call"
import { import {
PROTECTED, PROTECTED,
RoomType, RoomType,
@ -64,6 +71,9 @@
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom, isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
) )
const isCallActiveElsewhere = $derived(deriveIsCallActiveElsewhere(url, h))
const callBannerVisible = $derived($isCallActiveElsewhere)
// Reuses voiceConnectedHere (rather than re-deriving isVoiceRoom/callState) so it // Reuses voiceConnectedHere (rather than re-deriving isVoiceRoom/callState) so it
// can't diverge and stay true for a different voice room the call isn't targeting // can't diverge and stay true for a different voice room the call isn't targeting
// — that previously hid this room's messages/compose row while connected elsewhere. // — that previously hid this room's messages/compose row while connected elsewhere.
@ -487,16 +497,22 @@
}) })
</script> </script>
<div class={cx("flex min-h-0 flex-1 flex-col", isOverlayChat && "relative")}> <div
class={cx(
"flex min-h-0 flex-1 flex-col",
isOverlayChat && "max-md:relative",
// Desktop: chat is a real flex sibling beside the video pane rather than an
// absolute overlay on top of it, so opening/closing it actually resizes the
// video pane instead of just floating a card over it — see the sidebar's
// width transition below for why this fixes controls jumping on toggle.
voiceConnectedHere && "md:flex-row",
)}>
{#if h && voiceConnectedHere} {#if h && voiceConnectedHere}
<VideoCallContent <VideoCallContent
layout={$videoCallLayout} layout={$videoCallLayout}
{url} {url}
{h} {h}
class={cx( class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
"hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex",
isOverlayChat && "md:absolute md:inset-0",
)} />
{/if} {/if}
{#if h && voiceConnectedHere} {#if h && voiceConnectedHere}
@ -512,8 +528,21 @@
class={cx( class={cx(
"room flex min-h-0 min-w-0 flex-1 flex-col", "room flex min-h-0 min-w-0 flex-1 flex-col",
videoCallChatHidden && "hidden", videoCallChatHidden && "hidden",
// Desktop: a real sidebar column (fixed width, animated) instead of an absolute
// overlay card — its width transition is what makes the video pane (and the
// controls centered inside it, see VideoCallContent) resize and re-center in
// step with the chat opening/closing, instead of jumping.
voiceConnectedHere &&
"md:flex md:min-w-0 md:flex-none md:border-0 md:border-l md:border-line md:bg-surface md:shadow-lg md:transition-all md:duration-300 md:ease-in-out",
voiceConnectedHere &&
(isOverlayChat
? "md:w-72 md:opacity-100"
: "md:w-0 md:border-l-0 md:opacity-0 md:shadow-none md:pointer-events-none"),
isOverlayChat && isOverlayChat &&
"absolute inset-x-2 top-4 bottom-[calc(3.5rem+var(--saib))] z-popover overflow-hidden rounded-2xl border border-line bg-surface/95 shadow-lg md:left-auto md:right-4 md:bottom-4 md:w-96 md:max-w-[26rem] md:bg-surface/90", // Narrower width (w-72) plus generous insets keep the mobile card's edges from
// landing flush against a video tile's edge. No shadow on mobile — the card
// already reads as distinct from the video behind it via its own background.
"max-md:absolute max-md:inset-x-2 max-md:top-4 max-md:bottom-[calc(3.5rem+var(--saib))] max-md:z-popover max-md:overflow-hidden max-md:rounded-2xl max-md:border max-md:border-line max-md:bg-surface/95",
)}> )}>
{#if isOverlayChat} {#if isOverlayChat}
<Button <Button
@ -524,153 +553,184 @@
</Button> </Button>
{/if} {/if}
{#if h} <!-- Separate from the panel's own overflow: the panel itself can't clip its own
<RoomPinnedMessages {url} {h} /> overflow (that would also clip its desktop shadow), so this inner wrapper is
{/if} what keeps content from spilling out while the panel's width is animating
toward 0 on close. -->
<div <div
bind:this={element}
onscroll={onScroll}
class={cx( class={cx(
"room__content scroll-container", "flex min-h-0 min-w-0 flex-1 flex-col",
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex", voiceConnectedHere && "md:overflow-hidden",
)}> )}>
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted} {#if h}
<div class="py-20"> <RoomPinnedMessages {url} {h} />
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
<p class="opacity-75">You aren't currently a member of this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
{#if joining}
<Spinner size="sm" />
{:else}
<Icon icon={Login2} />
{/if}
Join Room
</Button>
{/if}
</div>
</div>
{:else}
{#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
{id}
class={cx("flex items-center py-2 text-xs transition-colors", {
"opacity-0": showFixedNewMessages,
})}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === addMemberKind}
<RoomItemAddMember {url} {event} />
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
{/if} {/if}
<div class="h-screen"></div>
</div>
<div <div
class={cx( bind:this={element}
"room__compose flex flex-row items-center gap-1 px-2", onscroll={onScroll}
showMobileVideoPanel && "max-md:hidden", class={cx(
// the connected bar (mic/camera/screenshare/settings/chat/leave) is too wide "room__content scroll-container",
// to sit beside the compose input on any screen size — give it its own row. showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
// gap-2 only applies here (the inline row stays flush with no gap). )}>
voiceConnectedHere && "flex-col items-stretch gap-2",
)}>
<div class="room__compose-inner min-w-0 flex-1">
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted} {#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<!-- pass --> <div class="py-20">
{:else if $room?.meta?.isRestricted() && $membershipStatus !== MembershipStatus.Granted} <div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
<div class="card m-4 flex flex-row items-center justify-between px-4 py-3"> <p class="opacity-75">You aren't currently a member of this room.</p>
<p class="opacity-75">Only members are allowed to post to this room.</p> {#if $membershipStatus === MembershipStatus.Pending}
{#if $membershipStatus === MembershipStatus.Pending} <Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}> <Icon icon={ClockCircle} />
<Icon icon={ClockCircle} /> Access Pending
Access Pending </Button>
</Button> {:else}
{:else} <Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}> {#if joining}
{#if joining} <Spinner size="sm" />
<Spinner size="sm" /> {:else}
{:else} <Icon icon={Login2} />
<Icon icon={Login2} /> {/if}
{/if} Join Room
Ask to Join </Button>
</Button> {/if}
{/if} </div>
</div> </div>
{:else} {:else}
<div> {#if loadingForward && elements.length > 0}
{#if parent} <p class="py-20 flex justify-center">
<RoomComposeParent {url} event={parent} clear={clearParent} verb="Replying to" /> <Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
{id}
class={cx("flex items-center py-2 text-xs transition-colors", {
"opacity-0": showFixedNewMessages,
})}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === addMemberKind}
<RoomItemAddMember {url} {event} />
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/if} {/if}
{#if sharedEvent} {/each}
<RoomComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" /> <p class="flex h-10 items-center justify-center py-20">
{/if} {#if loadingBackward}
{#if eventToEdit} <Spinner loading={loadingBackward}>Looking for messages...</Spinner>
<RoomComposeEdit clear={clearEventToEdit} /> {:else}
<Spinner>End of message history</Spinner>
{/if} {/if}
</p>
{/if}
<div class="h-screen"></div>
</div>
<div
class={cx(
// no explicit flex-row: it's flex's default direction, and Tailwind emits
// .flex-col before .flex-row in the compiled sheet, so having both classes
// present at once (base flex-row + conditional flex-col below) let flex-row
// always win — the compose row and call controls never actually stacked.
"room__compose flex items-center gap-1 px-2",
showMobileVideoPanel && "max-md:hidden",
// the connected bar (mic/camera/screenshare/settings/chat/leave) is too wide
// to sit beside the compose input on any screen size — give it its own row.
// gap-2 only applies here (the inline row stays flush with no gap).
voiceConnectedHere && "flex-col items-stretch gap-2",
)}>
<div class="room__compose-inner min-w-0 flex-1">
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $room?.meta?.isRestricted() && $membershipStatus !== MembershipStatus.Granted}
<div class="card m-4 flex flex-row items-center justify-between px-4 py-3">
<p class="opacity-75">Only members are allowed to post to this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
{#if joining}
<Spinner size="sm" />
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
</div>
{:else}
<div>
{#if parent}
<RoomComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if sharedEvent}
<RoomComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key initialValues}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
{initialValues}
bind:this={compose} />
{/key}
{/if}
</div>
{#if h}
<div
class={cx(
"hide-on-keyboard flex flex-shrink-0 items-center justify-center py-2",
// CallControlBar's connected pill is itself md:hidden here (a wider copy
// floats over the video instead — see VideoCallContent.svelte), so once
// this row actually stacks (flex-col, above) this wrapper has nothing to
// show on desktop and was otherwise left as an empty padded bar under
// the compose box. voiceConnectedHere is exactly the state where the
// pill is connected, so hiding it here is safe for the join/joining
// states, which don't hit this flex-col branch at all.
voiceConnectedHere && "md:hidden",
)}>
<CallControlBar {url} {h} hideConnectedOnDesktop />
</div> </div>
{#key initialValues}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
{initialValues}
bind:this={compose} />
{/key}
{/if} {/if}
</div> </div>
{#if h}
<div class="hide-on-keyboard flex flex-shrink-0 items-center justify-center py-2">
<CallControlBar {url} {h} hideConnectedOnDesktop />
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
{#if showScrollButton} {#if showScrollButton}
<div in:fade class="chat__scroll-down"> <div
in:fade
class={cx(
"chat__scroll-down",
callBannerVisible && "chat__scroll-down--banner",
isOverlayChat && "chat__scroll-down--overlay-chat",
)}>
<Button class="button button-neutral button-circle" onclick={scrollToBottom}> <Button class="button button-neutral button-circle" onclick={scrollToBottom}>
<Icon icon={AltArrowDown} /> <Icon icon={AltArrowDown} />
</Button> </Button>

View file

@ -190,14 +190,22 @@
} }
const panelChrome = $derived( const panelChrome = $derived(
cx( mobile
mobile && ? cx(
"flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-surface px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]", "flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-surface px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]",
!mobile && className,
"flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-surface px-2 pb-2 pt-4", )
className, : "flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-surface px-2 pb-2 pt-4",
),
) )
// Desktop: `className` (the instance's own visibility/sizing, e.g. "hidden ...
// md:flex") lives on this wrapper rather than on panelChrome, so the floating
// control bar below shares its box — a `relative` box that's already sized to
// the real (chat-sidebar-aware) video pane width, since RoomChat now lays the
// chat sidebar out as a flex sibling instead of an absolute overlay. Centering
// the controls against this box (instead of the viewport) is what keeps them
// centered in the actual remaining space as that width animates open/closed.
const desktopWrapperClass = $derived(cx("relative flex min-h-0 flex-1 flex-col", className))
</script> </script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)} {#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)}
@ -305,25 +313,35 @@
{/snippet} {/snippet}
{#if showVideoContent} {#if showVideoContent}
<div class={panelChrome}> {#if mobile}
{@render videoPanelBody()} <div class={panelChrome}>
</div> {@render videoPanelBody()}
<!-- fixed (viewport-relative), not absolute within panelChrome — panelChrome's </div>
flex-computed height isn't guaranteed to reach the real viewport bottom, so <!-- fixed (viewport-relative), not absolute within panelChrome — panelChrome's
an absolute child positioned against its box can end up overlapping the flex-computed height isn't guaranteed to reach the real viewport bottom, so
fixed bottom nav bar. Matches the same bottom-nav-clearing offset the old an absolute child positioned against its box can end up overlapping the
chat FAB used. fixed bottom nav bar. Matches the same bottom-nav-clearing offset the old
chat FAB used.
RoomChat mounts both a desktop and a mobile VideoCallContent instance at RoomChat mounts both a desktop and a mobile VideoCallContent instance at
once while connected (each gated by its own `class` prop, e.g. "md:hidden" once while connected, each gated by its own `class` prop — this instance
vs "hidden ... md:flex"), so this fixed bar needs the same mobile/desktop is the mobile one, so it's the only copy visible below md. -->
split — otherwise both instances render their own copy at the identical <div
screen position simultaneously. --> class="left-content pointer-events-none fixed right-sai z-popover flex justify-center bottom-[calc(4.5rem+var(--saib))] md:hidden">
<div <CallControlBar {url} {h} />
class={cx( </div>
"left-content pointer-events-none fixed right-sai z-popover justify-center bottom-[calc(4.5rem+var(--saib))] md:bottom-4", {:else}
mobile ? "flex md:hidden" : "hidden md:flex", <!-- desktopWrapperClass carries this instance's own visibility ("hidden ...
)}> md:flex") and is `relative`, so the control bar below is `absolute` against
<CallControlBar {url} {h} /> the real (chat-sidebar-aware) video pane box rather than the viewport —
</div> it re-centers automatically as that box's width animates. -->
<div class={desktopWrapperClass}>
<div class={panelChrome}>
{@render videoPanelBody()}
</div>
<div class="pointer-events-none absolute inset-x-0 bottom-4 z-popover flex justify-center">
<CallControlBar {url} {h} />
</div>
</div>
{/if}
{/if} {/if}

View file

@ -1,9 +1,30 @@
@layer components { @layer components {
/* z-popover (not z-feature) so it stacks above the overlay chat pane (also
z-popover) instead of landing behind it — same tier + later-in-DOM trick
RoomChat's "New Messages" chip already relies on. */
.chat__scroll-down { .chat__scroll-down {
@apply z-feature fixed right-4 bottom-28 md:bottom-16; @apply z-popover fixed right-4 bottom-28 md:bottom-16;
padding-bottom: var(--saib); padding-bottom: var(--saib);
} }
/* .chat__scroll-down is `fixed` (viewport-relative), so it ignores CallBanner's real
in-flow layout space entirely even though CallBanner shrinks the chat area above
it. Nudge the button up by CallBanner's own approximate rendered height (~3.5rem:
border-top + py-2 padding + its tallest child, the button-sm leave/mute controls)
so it clears the banner instead of landing behind it. */
.chat__scroll-down--banner {
@apply bottom-[calc(7rem+3.5rem)] md:bottom-[calc(4rem+3.5rem)];
}
/* On desktop, isOverlayChat (Split layout) floats the chat pane at md:bottom-24
in this same corner — .chat__scroll-down's default md:bottom-16 puts the
button's top edge past that pane's bottom edge, clipping into its rounded
corner. Drop below it instead. Mobile is untouched: there the pane fills
nearly the whole screen, so the button is meant to float over it. */
.chat__scroll-down--overlay-chat {
@apply md:bottom-10;
}
.chat-bubble { .chat-bubble {
@apply bg-surface-less text-content mx-1 mb-2 flex min-w-[100px] cursor-auto flex-col gap-1 rounded-2xl p-2 text-left lg:max-w-2xl; @apply bg-surface-less text-content mx-1 mb-2 flex min-w-[100px] cursor-auto flex-col gap-1 rounded-2xl p-2 text-left lg:max-w-2xl;
} }