Clean up voice room/chat layout

This commit is contained in:
Jon Staab 2026-08-27 16:54:30 -07:00
parent 2974faa2e9
commit 31890dfe90
6 changed files with 138 additions and 329 deletions

View file

@ -39,12 +39,9 @@
type Props = {
url: string
h: string
// suppress the full in-call control row on desktop — used where a wider,
// unobstructed copy of it already floats over the video panel
hideConnectedOnDesktop?: boolean
}
const {url, h, hideConnectedOnDesktop = false}: Props = $props()
const {url, h}: Props = $props()
const roomStore = $derived($rooms.forRoom(url, h))
const room = $derived($roomStore)
@ -67,15 +64,7 @@
const chatUnread = $derived($notifications.has(roomPath))
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 buttonClass = "button button-circle max-md:h-9 max-md:w-9"
const onChatToggle = () => {
videoCallLayout.update(p =>
@ -86,15 +75,13 @@
{#if joiningHere || connectedHere || showJoin}
<!-- every state shares this grid cell so switching between them cross-fades in
place instead of pushing/jumping past each other as flex siblings, and the
grid auto-sizes to whichever state is actually present (nothing reserves
layout space when e.g. the connected bar is display:none on desktop). -->
place instead of pushing/jumping past each other as flex siblings. -->
<div class="grid items-center justify-center">
{#if joiningHere}
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
class="pointer-events-auto col-start-1 row-start-1 flex h-10 items-center gap-1.5 rounded-full border border-line bg-surface/95 px-1.5 shadow-xl backdrop-blur-md">
class="col-start-1 row-start-1 flex h-10 items-center gap-1.5 rounded-full border border-line bg-surface/95 px-1.5 shadow-xl backdrop-blur-md">
<span
data-tip="Joining call…"
role="status"
@ -113,22 +100,14 @@
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
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",
isChatPanelActive && "md:gap-1 md:p-1",
hideConnectedOnDesktop && "md:hidden",
)}>
class="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">
<Button
data-tip={$callMicMuted ? "Unmute" : "Mute"}
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
aria-pressed={!$callMicMuted}
class={cx(
"button button-circle",
compactButtonClass,
$callMicMuted ? "button-neutral" : "button-primary",
)}
class={cx(buttonClass, $callMicMuted ? "button-neutral" : "button-primary")}
onclick={toggleMute}>
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={iconSize} />
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={4.5} />
</Button>
{#if $currentCallSession}
<Button
@ -136,14 +115,13 @@
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
aria-pressed={$currentCallSession.cameraOn}
class={cx(
"button button-circle",
compactButtonClass,
buttonClass,
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
)}
onclick={toggleCamera}>
<Icon
icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff}
size={iconSize} />
size={4.5} />
</Button>
{#if !Capacitor.isNativePlatform()}
<Button
@ -153,33 +131,32 @@
: "Share screen"}
aria-pressed={$currentCallSession.screenShareOn}
class={cx(
"button button-circle",
compactButtonClass,
buttonClass,
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
)}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={iconSize} />
<Icon icon={Monitor} size={4.5} />
</Button>
{/if}
{/if}
<Button
data-tip="Call settings"
aria-label="Call settings"
class={cx("button button-circle button-neutral", compactButtonClass)}
class={cx(buttonClass, "button-neutral")}
onclick={openCallSettings}>
<Icon icon={Settings} size={iconSize} />
<Icon icon={Settings} size={4.5} />
</Button>
<Button
data-tip="Toggle chat"
aria-label="Toggle chat panel"
aria-pressed={isChatPanelActive}
class={cx(
"button button-circle relative",
compactButtonClass,
buttonClass,
"relative",
isChatPanelActive ? "button-primary" : "button-neutral",
)}
onclick={onChatToggle}>
<Icon icon={ChatRound} size={iconSize} />
<Icon icon={ChatRound} size={4.5} />
{#if chatUnread}
<span
class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface"
@ -190,17 +167,14 @@
<Button
data-tip="Leave call"
aria-label="Leave voice room"
class={cx("button button-circle button-error", compactButtonClass)}
class={cx(buttonClass, "button-error")}
onclick={leaveVoiceRoom}>
<Icon icon={EndCall} size={isChatPanelActive ? 4 : 5} />
<Icon icon={EndCall} size={5} />
</Button>
</div>
{/if}
{#if showJoin}
<div
in:fade={{duration: 160}}
out:fade={{duration: 120}}
class="pointer-events-auto col-start-1 row-start-1">
<div in:fade={{duration: 160}} out:fade={{duration: 120}} class="col-start-1 row-start-1">
<Button
data-tip="Join call"
aria-label="Join voice room"

View file

@ -32,14 +32,7 @@
import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user} from "@app/core"
import {joinRoom, leaveRoom} from "@app/access"
import {
CallState,
callTargetRoom,
callState,
VideoCallLayout,
videoCallLayout,
deriveIsCallActiveElsewhere,
} from "@app/call"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
import {
PROTECTED,
RoomType,
@ -71,40 +64,22 @@
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
// 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.
const showMobileVideoPanel = $derived(
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
// Basic pass at issue #121's "chat overlaid on video" idea: float the chat panel over
// the full-bleed video instead of splitting the screen into fixed columns, so the call
// stays the visual focus. The panel opens from — and collapses back to — a chat FAB.
const isOverlayChat = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
const videoCallChatHidden = $derived(
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
// During a call the chat is toggled from the call controls — it takes over the pane,
// or sits beside the video once there's room for both. Reuses voiceConnectedHere
// rather than re-deriving isVoiceRoom/callState so it can't stay true for a voice
// room the call isn't targeting, which would hide that room's messages.
const callChatOpen = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
const callChatHidden = $derived(voiceConnectedHere && !callChatOpen)
const roomPath = h ? makeRoomPath(url, h) : undefined
const closeChat = () => videoCallLayout.set(VideoCallLayout.Video)
$effect(() => {
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
if (roomPath && voiceConnectedHere && !videoCallChatHidden) {
deferredRoomPath.set(callChatHidden ? roomPath : undefined)
if (roomPath && callChatOpen) {
setChecked(roomPath)
}
// The messages pane (and its scroll position) is offscreen while the call view is
// showing — don't leave its floating "scroll down"/"new messages" chips stranded.
if (videoCallChatHidden) {
showScrollButton = false
showFixedNewMessages = false
}
})
onDestroy(() => deferredRoomPath.set(undefined))
@ -120,8 +95,8 @@
return
}
// Land on the call view (chat as a FAB) as soon as the call starts here, whether
// or not anyone's camera is on — chat is one tap away via the FAB.
// Land on the call view as soon as the call starts here, whether or not anyone's
// camera is on — chat is one tap away via the control bar's chat toggle.
if (!wasConnectedHere) {
videoCallLayout.set(VideoCallLayout.Video)
wasConnectedHere = true
@ -499,52 +474,39 @@
<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",
"room flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && "bg-surface xl:flex-row xl:overflow-hidden",
)}>
{#if h && voiceConnectedHere}
<VideoCallContent
layout={$videoCallLayout}
{url}
{h}
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
{/if}
{#if h && voiceConnectedHere}
<VideoCallContent
layout={$videoCallLayout}
mobile
{url}
{h}
class={cx("md:hidden", isOverlayChat && "absolute inset-0")} />
<!-- The controls belong to the video, so they share its column and the chat panel
runs the full height beside it. Below xl the open chat takes the pane over
instead of sharing it, leaving this column to shrink to just the controls. -->
<div
class={cx(
"flex min-h-0 min-w-0 flex-col max-xl:order-last",
callChatOpen ? "xl:flex-1" : "flex-1",
)}>
<VideoCallContent {url} {h} class={cx(callChatOpen && "max-xl:hidden")} />
<div class="hide-on-keyboard flex shrink-0 items-center justify-center pb-2">
<CallControlBar {url} {h} />
</div>
</div>
{/if}
<!-- Closed at xl, the negative margin takes the panel out of the flex line without
changing its width, parking it past the room's clipped right edge — so the
transition slides it in at full size rather than squeezing its contents. It
only drops out of the layout below xl, where it has nothing to share the pane
with. inert (not just pointer-events) so a parked panel leaves the tab order. -->
<div
inert={callChatHidden}
class={cx(
"room flex min-h-0 min-w-0 flex-1 flex-col",
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.
"relative flex min-h-0 min-w-0 flex-1 flex-col",
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 &&
// 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",
"xl:w-96 xl:flex-none xl:border-l xl:border-line xl:transition-[margin-right] xl:duration-200",
callChatHidden && "max-xl:hidden xl:-mr-96",
)}>
{#if isOverlayChat}
{#if callChatOpen}
<Button
aria-label="Close room chat"
class="button button-neutral button-xs button-circle absolute right-2 top-2 z-popover"
@ -553,26 +515,12 @@
</Button>
{/if}
<!-- Separate from the panel's own overflow: the panel itself can't clip its own
overflow (that would also clip its desktop shadow), so this inner wrapper is
what keeps content from spilling out while the panel's width is animating
toward 0 on close. -->
<div
class={cx(
"flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && "md:overflow-hidden",
)}>
{#if h}
<RoomPinnedMessages {url} {h} />
{/if}
{#if h}
<RoomPinnedMessages {url} {h} />
{/if}
<div
bind:this={element}
onscroll={onScroll}
class={cx(
"room__content scroll-container",
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
)}>
<div class="relative flex min-h-0 flex-1 flex-col">
<div bind:this={element} onscroll={onScroll} class="room__content scroll-container">
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
@ -643,106 +591,77 @@
<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}
{#if showScrollButton}
<div in:fade class="absolute bottom-2 right-4 z-popover">
<Button class="button button-neutral button-circle" onclick={scrollToBottom}>
<Icon icon={AltArrowDown} />
</Button>
</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 />
{/if}
{#if showFixedNewMessages}
<div
transition:fly={{duration: 200}}
class="absolute inset-x-0 top-2 z-popover flex justify-center">
<Button class="button button-primary button-xs button-pill" onclick={scrollToNewMessages}>
New Messages
</Button>
</div>
{/if}
</div>
<div class="room__compose flex items-center gap-1 px-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 && isVoiceRoom && !voiceConnectedHere}
<div class="hide-on-keyboard flex shrink-0 items-center justify-center py-2">
<CallControlBar {url} {h} />
</div>
{/if}
</div>
</div>
</div>
{#if showScrollButton}
<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}>
<Icon icon={AltArrowDown} />
</Button>
</div>
{/if}
{#if showFixedNewMessages}
<div class="relative z-popover flex justify-center">
<div transition:fly={{duration: 200}} class="fixed top-12 pt-sai">
<Button class="button button-primary button-xs button-pill" onclick={scrollToNewMessages}>
New Messages
</Button>
</div>
</div>
{/if}

View file

@ -7,10 +7,8 @@
import Icon from "@lib/components/Icon.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallTile from "@app/components/VideoCallTile.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte"
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
import {
VideoCallLayout,
toggleVideoPrimaryTile,
videoPrimaryTileKey,
currentCallSession,
@ -25,8 +23,6 @@
import {deriveDisplaysByPubkey} from "@app/social"
type Props = {
layout: VideoCallLayout
mobile?: boolean
url: string
h: string
class?: string
@ -42,16 +38,7 @@
type TileLayoutVariant = "spotlight" | "default" | "strip"
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
const isViewingCurrentCallRoom = $derived(
$callTargetRoom?.url === url && $callTargetRoom?.h === h,
)
const showVideoContent = $derived(
isViewingCurrentCallRoom &&
(layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
)
const {url, h, class: className}: Props = $props()
const videoTiles = $derived.by(() => {
const session = $currentCallSession
@ -188,24 +175,6 @@
const spotlightHandlerFor = (key: string) => () => {
toggleVideoPrimaryTile(key)
}
const panelChrome = $derived(
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))]",
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>
{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)}
@ -281,7 +250,7 @@
<div
bind:clientWidth={gridWidth}
bind:clientHeight={gridHeight}
class="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
class="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]">
{#if tileGrid}
<div class="flex flex-col items-center gap-2">
{#each tileGrid.rows as row, rowIndex (rowIndex)}
@ -312,36 +281,6 @@
{/if}
{/snippet}
{#if showVideoContent}
{#if mobile}
<div class={panelChrome}>
{@render videoPanelBody()}
</div>
<!-- fixed (viewport-relative), not absolute within panelChrome — panelChrome's
flex-computed height isn't guaranteed to reach the real viewport bottom, so
an absolute child positioned against its box can end up overlapping the
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
once while connected, each gated by its own `class` prop — this instance
is the mobile one, so it's the only copy visible below md. -->
<div
class="left-content pointer-events-none fixed right-sai z-popover flex justify-center bottom-[calc(4.5rem+var(--saib))] md:hidden">
<CallControlBar {url} {h} />
</div>
{:else}
<!-- desktopWrapperClass carries this instance's own visibility ("hidden ...
md:flex") and is `relative`, so the control bar below is `absolute` against
the real (chat-sidebar-aware) video pane box rather than the viewport —
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}
<div class={cx("flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-2", className)}>
{@render videoPanelBody()}
</div>

View file

@ -170,7 +170,7 @@ body.keyboard-open {
body.keyboard-open .hide-on-keyboard {
@apply hidden;
}
body.keyboard-open .room__compose {
body.keyboard-open .room {
@apply mb-0;
}

View file

@ -1,30 +1,4 @@
@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 {
@apply z-popover fixed right-4 bottom-28 md:bottom-16;
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 {
@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;
}

View file

@ -1,6 +1,9 @@
@layer components {
.room {
@apply relative;
/* The mobile nav is fixed on top of the page, so the room view reserves its
height here rather than in each of its children — otherwise an overlaid or
floating child ends up stacking a second copy of the offset. */
@apply relative mb-14 md:mb-0;
.room__content {
@apply z-feature flex max-h-screen min-h-0 w-full min-w-0 flex-col flex-col-reverse gap-2 overflow-x-hidden overflow-y-auto;
@ -19,7 +22,7 @@
}
.room__compose {
@apply z-compose relative mb-14 shrink-0 md:mb-0;
@apply z-compose relative shrink-0;
}
.room__compose .room__compose-inner {