flotilla/src/app/components/RoomChat.svelte

748 lines
23 KiB
Svelte
Raw Normal View History

<script lang="ts">
import {onDestroy, onMount, untrack} from "svelte"
import {readable} from "svelte/store"
import {page} from "$app/stores"
import {navigate} from "@app/modal"
import type {Readable} from "svelte/store"
import {debounce} from "throttle-debounce"
2026-07-28 16:16:26 +00:00
import cx from "classnames"
2026-09-21 17:49:17 +00:00
import {now, ifLet, ago, spec, MINUTE} from "@welshman/lib"
2026-08-11 21:54:05 +00:00
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
2026-09-14 23:34:45 +00:00
import {relay, stamp, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
import {Message} from "@welshman/domain"
2026-08-21 15:31:53 +00:00
import {MembershipStatus} from "@welshman/app"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl"
import {fade, fly} from "@lib/transition"
import {documentActive} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
2026-08-31 15:07:09 +00:00
import VirtualList from "@lib/components/VirtualList.svelte"
import type {VirtualListController} from "@lib/components/VirtualList.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import ComposeEdit from "@app/components/ComposeEdit.svelte"
import ComposeParent from "@app/components/ComposeParent.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte"
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte"
2026-09-14 23:34:45 +00:00
import {deletes, relays, rooms, thunks, user, writer} from "@app/core"
2026-08-21 15:31:53 +00:00
import {joinRoom, leaveRoom} from "@app/access"
2026-08-27 23:54:30 +00:00
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
2026-07-28 16:16:26 +00:00
import {
RoomType,
deriveUserRoomMembershipStatus,
getRoomType,
2026-08-31 15:57:39 +00:00
groupRoomMessages,
2026-07-28 16:16:26 +00:00
prependParent,
} from "@app/rooms"
import {loadSendDelay} from "@app/settings"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {pageLoading} from "@app/loading"
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
2026-09-21 17:49:17 +00:00
import {getPermalinkTarget, highlightedEvent, makeRoomPath} from "@app/routes"
2026-08-11 21:54:05 +00:00
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast"
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
const context = makeFeedContext({relays: [url]})
onDestroy(context.cleanup)
2026-07-28 16:16:26 +00:00
const room = h ? $rooms.forRoom(url, h) : readable(undefined)
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h)
const voiceConnectedHere = $derived(
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
)
// voiceConnectedHere can't stay true for a voice room the call isn't targeting.
2026-08-27 23:54:30 +00:00
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(() => {
2026-08-27 23:54:30 +00:00
deferredRoomPath.set(callChatHidden ? roomPath : undefined)
if (roomPath && callChatOpen) {
setChecked(roomPath)
}
})
onDestroy(() => deferredRoomPath.set(undefined))
let wasConnectedHere = $state(false)
$effect(() => {
if (!voiceConnectedHere) {
if ($callState !== CallState.Connected) {
videoCallLayout.set(VideoCallLayout.Chat)
}
wasConnectedHere = false
return
}
// Chat is one tap away on the control bar, so land on the call view whether or not a camera is on.
if (!wasConnectedHere) {
videoCallLayout.set(VideoCallLayout.Video)
wasConnectedHere = true
}
})
2026-07-28 16:16:26 +00:00
const shouldProtect = $relays.hasNip(url, 70)
const membershipStatus = h
? deriveUserRoomMembershipStatus(url, h)
: readable(MembershipStatus.Granted)
const at = $derived(parseInt($page.url.searchParams.get("at")!))
const inviteCode = $derived($page.url.searchParams.get("code") || "")
2026-09-21 17:49:17 +00:00
const target = $derived(getPermalinkTarget($page.url))
const join = async () => {
if (h) {
joining = true
try {
2026-08-21 15:31:53 +00:00
const message = await joinRoom(url, h, inviteCode)
2026-07-28 16:16:26 +00:00
2026-08-21 15:31:53 +00:00
if (message) {
pushToast({theme: "error", message})
} else {
// Restart the feed now that we're a member
start(at || now())
}
} finally {
joining = false
}
}
}
const leave = async () => {
if (h) {
leaving = true
2026-08-21 15:31:53 +00:00
try {
2026-08-21 15:31:53 +00:00
const message = await leaveRoom(url, h)
2026-08-21 15:31:53 +00:00
if (message) {
pushToast({theme: "error", message})
2026-08-21 15:31:53 +00:00
} else {
pushToast({message: "You have left the room."})
}
} finally {
leaving = false
}
}
}
const replyTo = (event: TrustedEvent) => {
parent = event
compose?.focus()
}
const clearParent = () => {
parent = undefined
}
const clearShare = () => {
share = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async ({content, tags}: EventContent) => {
2026-08-11 21:54:05 +00:00
if (!content && !sharedEvent) {
return
}
try {
2026-07-28 16:16:26 +00:00
const protect = await shouldProtect
if (eventToEdit) {
// Don't do anything if message hasn't changed
if (eventToEdit.content === content) {
return
}
2026-09-14 23:34:45 +00:00
// Delete the previous message, to be republished below with the same timestamp
2026-07-28 16:16:26 +00:00
const command = await $deletes.deleteEvent($state.snapshot(eventToEdit), w =>
w.setProtected(protect),
)
command.publishToRelays([url])
}
// A share is a quote rather than a reply, so setParent prepends the reply's own reference ahead of it.
2026-08-11 21:54:05 +00:00
if (sharedEvent) {
2026-09-14 23:34:45 +00:00
;({content, tags} = await prependParent(sharedEvent, {content, tags}, url))
}
const eventWriter = writer(Message)
.setContent(content)
.addTags(...tags)
.setProtected(protect)
if (h) {
eventWriter.setRoom(url, h)
} else {
eventWriter.forceRoutes(relay(url))
}
if (parent) {
2026-09-14 23:34:45 +00:00
eventWriter.setParent(parent)
}
const delay = await loadSendDelay()
2026-07-28 16:16:26 +00:00
const thunk = $thunks.publish({
relays: [url],
2026-09-14 23:34:45 +00:00
event: stamp(await eventWriter.renderTemplate(), eventToEdit?.created_at),
delay,
})
if (delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
} finally {
clearParent()
clearShare()
clearEventToEdit()
}
}
2026-08-31 15:07:09 +00:00
const getElementKey = (element: {id: string}) => element.id
// In screen terms rather than scroll terms, so the reversed layout doesn't come into it.
2026-08-31 15:47:55 +00:00
const topOf = (target: HTMLElement) =>
target.getBoundingClientRect().top - element!.getBoundingClientRect().top
// Messages arriving at the origin push the pinned row away, and the browser does not compensate.
const keepPinned = (caughtUp: boolean) => {
2026-08-31 15:47:55 +00:00
const target = pinned && element?.querySelector(`[data-event="${pinned.id}"]`)
if (target instanceof HTMLElement && pinned) {
const origin = Math.abs(element!.scrollTop)
2026-08-31 15:47:55 +00:00
// A pin clamped at the origin belongs at the live end once the forward walk catches up.
if (pinned.clamped && caughtUp && topOf(target) > origin) {
release()
// Only a scroll event clears the flag, so claiming one that never fires discards the next.
if (origin >= 1) {
isProgrammaticScroll = true
element!.scrollTo({top: 0})
}
} else {
const drift = topOf(target) - pinned.top
if (Math.abs(drift) >= 1) {
isProgrammaticScroll = true
element!.scrollBy({top: drift})
}
2026-08-31 15:47:55 +00:00
}
}
}
// Any scroll event at all fires while content loads, so taking over has to be a real gesture
const release = () => {
released = true
pinned = undefined
}
// A row deep in history renders on the next flush after it is asked for, so the lookup waits for it.
2026-08-31 15:47:55 +00:00
const scrollToRow = (
id: string,
{
behavior = "auto",
highlight = false,
pin = false,
}: {behavior?: ScrollBehavior; highlight?: boolean; pin?: boolean} = {},
) => {
virtualList?.reveal(id)
requestAnimationFrame(() => {
const target = element?.querySelector(`[data-event="${id}"]`) ?? document.getElementById(id)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
target.scrollIntoView({behavior, block: "center"})
if (highlight) {
2026-09-21 17:39:44 +00:00
highlightedEvent.set(id)
2026-08-31 15:47:55 +00:00
}
if (pin) {
// A scroll that ended at the origin is one the browser clamped there.
pinned = {id, top: topOf(target), clamped: Math.abs(element!.scrollTop) < 1}
2026-08-31 15:47:55 +00:00
}
}
jumpSettled = true
})
}
const manageScrollPosition = () => {
scrolledUp = Math.abs(element?.scrollTop || 0) > 1500
const newMessages = document.getElementById("new-messages")
if (newMessagesSeen) {
showFixedNewMessages = false
} else if (newMessages) {
const {y} = newMessages.getBoundingClientRect()
if (y > 0 && y < 300) {
newMessagesSeen = true
showFixedNewMessages = false
} else {
showFixedNewMessages = y < 0
}
}
2026-08-31 15:47:55 +00:00
if (!released && !pinned && !isNaN(at)) {
2026-09-21 17:49:17 +00:00
// The pointer only helps once that message has arrived, so the anchor still carries the jump.
const pointedAt = target ? $events.find(spec({id: target})) : undefined
const targetEvent = pointedAt ?? $events.find(event => event.created_at >= at)
if (targetEvent) {
2026-08-31 15:47:55 +00:00
scrollToRow(targetEvent.id, {highlight: true, pin: true})
} else {
// Nothing to jump to yet, so don't hold the room back waiting for it
jumpSettled = true
}
}
}
const onScroll = () => {
if (!isProgrammaticScroll) {
isUserScrolling = true
clearIsUserScrolling()
manageScrollPosition()
}
isProgrammaticScroll = false
}
2026-08-31 15:47:55 +00:00
const scrollToNewMessages = () => scrollToRow("new-messages", {behavior: "smooth"})
// Anchoring a fresh feed on the present is what puts the reader at the live end.
const scrollToBottom = async () => {
const anchored = !isNaN(at)
if (anchored) {
release()
await navigate($page.url.pathname, {replaceState: true})
start(now())
}
element?.scrollTo({top: 0, behavior: anchored ? "auto" : "smooth"})
}
// A tab can be `visible` but unfocused, so `document.hidden` alone doesn't say the room is watched.
const onActiveChange = (active: boolean) => {
if (!active) {
lastVisibleAt = now()
2026-07-28 16:16:26 +00:00
} else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) {
newMessagesAfter = lastVisibleAt
newMessagesBefore = now()
newMessagesSeen = false
}
}
let joining = $state(false)
let leaving = $state(false)
2026-08-31 15:47:55 +00:00
let jumpSettled = $state(false)
let released = false
let pinned: Maybe<{id: string; top: number; clamped: boolean}>
let feedAnchor: Maybe<number>
let isProgrammaticScroll = $state(false)
let isUserScrolling = $state(false)
2026-08-31 15:07:09 +00:00
let virtualList: Maybe<VirtualListController> = $state()
2026-08-30 00:42:07 +00:00
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let newer: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
2026-08-11 21:54:05 +00:00
let share: Maybe<Share> = $state()
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
let lastVisibleAt = now()
let newMessagesAfter = $state($checked[$page.url.pathname])
let newMessagesBefore = $state(now())
let newMessagesSeen = false
let showFixedNewMessages = $state(false)
let scrolledUp = $state(false)
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
// A link into history renders the newest messages first and only then scrolls.
2026-08-31 15:47:55 +00:00
const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
2026-08-30 00:42:07 +00:00
const reachedStartOfHistory = $derived($older?.status === "exhausted")
// The page bar carries one loader for both directions of an anchored walk.
$effect(() => {
pageLoading.set(isFeedLoading($older) || isFeedLoading($newer))
return () => pageLoading.set(false)
})
// Sharing into the room already on screen doesn't re-create this component.
2026-08-11 21:54:05 +00:00
$effect(() => {
if ($pendingShare) {
share = $pendingShare
pendingShare.set(undefined)
}
})
const sharedEvent = $derived(share?.type === "event" ? share.value : undefined)
const initialValues = $derived.by((): Share | undefined => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
const clearIsUserScrolling = debounce(150, () => {
isUserScrolling = false
})
2026-08-31 15:57:39 +00:00
const elements = $derived(
groupRoomMessages({
events: $events,
pubkey: $user.pubkey,
addMemberKind,
unreadAfter: newMessagesAfter,
unreadBefore: newMessagesBefore,
}),
)
// The window only stops short of the present after jumping into history.
const windowStopsShort = $derived(!isNaN(at) && $newer?.status !== "exhausted")
// While the window stops short, the bottom of the container isn't the bottom of the conversation.
const showScrollButton = $derived(scrolledUp || windowStopsShort)
2026-08-30 00:42:07 +00:00
// `at` is NaN when there is no anchor, and NaN never equals itself.
$effect(() => {
if (!isNaN(at) && feedAnchor && at !== feedAnchor) {
released = false
pinned = undefined
jumpSettled = false
untrack(() => start(at))
}
})
$effect(() => {
if (elements.length > 0 && !isUserScrolling) {
requestAnimationFrame(manageScrollPosition)
}
})
// The forward walk can reach the present after the last message does, so the effect watches both.
2026-08-31 15:47:55 +00:00
$effect(() => {
const caughtUp = !windowStopsShort
2026-08-31 15:47:55 +00:00
if (elements.length > 0) {
const frame = requestAnimationFrame(() => keepPinned(caughtUp))
2026-08-31 15:47:55 +00:00
return () => cancelAnimationFrame(frame)
}
})
// Declaring these as handlers would claim the transcript is a control.
2026-08-31 15:47:55 +00:00
$effect(() => {
if (element) {
const target = element
for (const type of ["wheel", "touchmove", "keydown"]) {
target.addEventListener(type, release, {passive: true})
}
return () => {
for (const type of ["wheel", "touchmove", "keydown"]) {
target.removeEventListener(type, release)
}
}
}
})
const start = (anchor: number) => {
cleanup?.()
feedAnchor = anchor
const feed = makeFeed({
relays: [url],
at: anchor,
filters: [
h ? {kinds: [MESSAGE, addMemberKind], "#h": [h]} : {kinds: [MESSAGE, addMemberKind]},
],
onEvent: context.add,
})
// The container is reversed, so scrolling away from the origin reaches older messages.
2026-08-30 00:42:07 +00:00
older = makeScrollLoader(element!, feed.loadOlder)
newer = makeScrollLoader(element!, feed.loadNewer, {reverse: true})
events = feed.events
2026-08-30 00:42:07 +00:00
cleanup = () => {
older?.stop()
newer?.stop()
feed.cleanup()
}
}
const onEscape = () => {
clearParent()
clearShare()
eventToEdit = undefined
}
const canEditEvent = (event: TrustedEvent) =>
2026-07-28 16:16:26 +00:00
event.pubkey === $user.pubkey && event.created_at >= ago(5, MINUTE)
const onEditEvent = (event: TrustedEvent) => {
clearParent()
clearShare()
eventToEdit = event
}
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
onMount(() => {
// Defer rendering until navigation finishes
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => start(at || now()))
})
const unsubscribeActive = documentActive.subscribe(onActiveChange)
return () => {
cleanup?.()
unsubscribeActive()
cancelAnimationFrame(frame)
}
})
</script>
2026-08-31 15:57:39 +00:00
{#snippet membershipButton(label: string)}
{#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}
{label}
</Button>
{/if}
{/snippet}
<div
class={cx(
2026-08-27 23:54:30 +00:00
"room flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && "bg-surface xl:flex-row xl:overflow-hidden",
)}>
{#if h && voiceConnectedHere}
2026-08-27 23:54:30 +00:00
<!-- 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}
2026-08-27 23:54:30 +00:00
<!-- 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
2026-08-27 23:54:30 +00:00
inert={callChatHidden}
class={cx(
2026-08-27 23:54:30 +00:00
"relative flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere &&
2026-08-27 23:54:30 +00:00
"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",
)}>
2026-08-27 23:54:30 +00:00
{#if callChatOpen}
<Button
aria-label="Close room chat"
class="button button-neutral button-xs button-circle absolute right-2 top-2 z-popover"
onclick={closeChat}>
<Icon icon={Close} size={4} />
</Button>
{/if}
2026-08-27 23:54:30 +00:00
{#if h}
<RoomPinnedMessages {url} {h} />
{/if}
2026-08-27 23:54:30 +00:00
<div class="relative flex min-h-0 flex-1 flex-col">
2026-08-31 15:47:55 +00:00
<div
bind:this={element}
onscroll={onScroll}
class={cx("room__content scroll-container transition-opacity", {
"opacity-0": awaitingJump,
})}>
{#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">
<p class="opacity-75">You aren't currently a member of this room.</p>
2026-08-31 15:57:39 +00:00
{@render membershipButton("Join Room")}
</div>
</div>
{:else}
2026-08-31 15:07:09 +00:00
<VirtualList
items={elements}
getKey={getElementKey}
container={element}
bind:controller={virtualList}>
2026-08-31 15:57:39 +00:00
{#snippet row(item)}
{#if item.type === "new-messages"}
2026-08-31 15:07:09 +00:00
<div
2026-08-31 15:57:39 +00:00
id={item.id}
2026-08-31 15:07:09 +00:00
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>
2026-08-31 15:57:39 +00:00
{:else if item.type === "date"}
<Divider>{item.value}</Divider>
{:else if item.value.kind === addMemberKind}
<RoomItemAddMember {url} event={item.value} />
{:else}
2026-08-31 15:57:39 +00:00
<RoomItem
{url}
{replyTo}
{context}
event={item.value}
showPubkey={item.showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
2026-08-31 15:07:09 +00:00
{/snippet}
</VirtualList>
<p class="flex h-10 items-center justify-center py-20">
{#if reachedStartOfHistory}
2026-08-29 17:22:57 +00:00
End of message history
2026-09-21 17:49:17 +00:00
{:else}
<Spinner loading>Looking for messages...</Spinner>
{/if}
</p>
{/if}
<div class="h-screen"></div>
</div>
2026-08-27 23:54:30 +00:00
{#if showScrollButton}
<div in:fade class="absolute bottom-2 right-4 z-popover">
<Button
aria-label="Jump to newest"
class="button button-neutral button-circle"
onclick={scrollToBottom}>
2026-08-27 23:54:30 +00:00
<Icon icon={AltArrowDown} />
</Button>
</div>
2026-08-27 23:54:30 +00:00
{/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>
2026-08-31 15:57:39 +00:00
{@render membershipButton("Ask to Join")}
</div>
2026-08-27 23:54:30 +00:00
{:else}
<div class="flex flex-col gap-px">
2026-08-27 23:54:30 +00:00
{#if parent}
<ComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
2026-08-27 23:54:30 +00:00
{/if}
{#if sharedEvent}
<ComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
2026-08-27 23:54:30 +00:00
{/if}
{#if eventToEdit}
<ComposeEdit clear={clearEventToEdit} />
2026-08-27 23:54:30 +00:00
{/if}
</div>
{#key initialValues}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
{initialValues}
bind:this={compose} />
{/key}
{/if}
</div>
2026-08-27 23:54:30 +00:00
{#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>