flotilla/src/app/components/RoomChat.svelte

794 lines
26 KiB
Svelte
Raw Normal View History

<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {readable} from "svelte/store"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store"
import {debounce} from "throttle-debounce"
2026-07-28 16:16:26 +00:00
import cx from "classnames"
import {now, ifLet, int, formatTimestampAsDate, ago, 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-07-28 16:16:26 +00:00
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
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 RoomComposeEdit from "@app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.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-07-28 16:16:26 +00:00
import {deletes, relays, rooms, thunks, user} 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 {
PROTECTED,
RoomType,
deriveUserRoomMembershipStatus,
getRoomType,
prependParent,
} from "@app/rooms"
import {userSettingsValues} from "@app/settings"
2026-08-30 00:42:07 +00:00
import {makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
import {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,
)
2026-08-27 23:54:30 +00:00
// 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(() => {
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
}
2026-08-27 23:54:30 +00:00
// 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
}
})
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") || "")
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()
}
} 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 {
if (h) {
tags.push(["h", h])
}
2026-07-28 16:16:26 +00:00
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
let template: EventContent & {created_at?: number} = {content, tags}
if (eventToEdit) {
// Don't do anything if message hasn't changed
if (eventToEdit.content === content) {
return
}
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
2026-07-28 16:16:26 +00:00
const command = await $deletes.deleteEvent($state.snapshot(eventToEdit), w =>
w.setProtected(protect),
)
command.publishToRelays([url])
}
2026-08-11 21:54:05 +00:00
if (sharedEvent) {
template = await prependParent(sharedEvent, template, url)
}
if (parent) {
2026-07-28 16:16:26 +00:00
template = await prependParent(parent, template, url)
}
2026-07-28 16:16:26 +00:00
const thunk = $thunks.publish({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
if ($userSettingsValues.send_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
2026-08-31 15:47:55 +00:00
// The list renders from the newest message outward, so a row deep in history isn't on the page
// until it's asked for — and asking renders it on the next flush, which the lookup has to wait
// for. Messages carry the id as a data attribute; the new-messages divider as its element id.
// Where a row sits inside the scroll container, in screen terms rather than scroll terms, so
// the reversed layout doesn't come into it
const topOf = (target: HTMLElement) =>
target.getBoundingClientRect().top - element!.getBoundingClientRect().top
// Messages loading in below the pinned row are inserted at the scroll origin, which pushes
// everything else away from it. The browser has no reason to compensate for that, so put the
// row back where it was. scrollBy is in visual terms, so this reads the same way either way up.
const keepPinned = () => {
const target = pinned && element?.querySelector(`[data-event="${pinned.id}"]`)
if (target instanceof HTMLElement && pinned) {
const drift = topOf(target) - pinned.top
if (Math.abs(drift) >= 1) {
isProgrammaticScroll = true
element!.scrollBy({top: drift})
}
}
}
// Any scroll event at all fires while content loads, so taking over has to be a real gesture
const release = () => {
released = true
pinned = undefined
}
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) {
target.classList.add("highlight-target")
}
if (pin) {
pinned = {id, top: topOf(target)}
}
}
jumpSettled = true
})
}
const manageScrollPosition = () => {
// Only treat an `at` jump as "scrolled up" when it targets an event below the
// newest one; jumping to the most recent message already lands us at the bottom.
const newestEvent = $events[$events.length - 1]
const atIsBelowNewest = !isNaN(at) && newestEvent !== undefined && at < newestEvent.created_at
showScrollButton = atIsBelowNewest || 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)) {
const targetEvent = $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"})
const scrollToBottom = () => {
if (!isNaN(at)) {
goto($page.url.pathname, {replaceState: true})
} else {
element?.scrollTo({top: 0, behavior: "smooth"})
}
}
// A tab can be `visible` but unfocused (user alt-tabbed to another app), so we
// can't rely on document.hidden alone to know the room is actually being 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}>
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 showScrollButton = $state(false)
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
2026-08-31 15:47:55 +00:00
// A link into history renders the newest messages first and only then scrolls, so the room is
// held back for that frame rather than showing the wrong end of the conversation and jumping.
const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
2026-08-30 00:42:07 +00:00
// There is always more history until the feed says otherwise, so this stays up rather than
// blinking between spans while it walks a quiet room.
const loadingBackward = $derived($older?.status !== "exhausted")
const reachedStartOfHistory = $derived($older?.status === "exhausted")
2026-08-11 21:54:05 +00:00
// Claim the share once we're on screen. Sharing into the room you're already looking at
// doesn't re-create this component, so this can't be read once on mount.
$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
})
const elements = $derived.by(() => {
const elements = []
const seen = new Set()
let previousDate
let previousKind
let previousPubkey
let previousCreatedAt = 0
let newMessagesSeen = false
if (events) {
2026-07-28 16:16:26 +00:00
const lastUserEvent = $events.findLast(e => e.pubkey === $user.pubkey)
// Adjust the boundary to account for messages that came from a different device
const adjustedAfter =
newMessagesAfter && lastUserEvent
? Math.max(lastUserEvent.created_at, newMessagesAfter)
: newMessagesAfter
for (const event of $events) {
if (seen.has(event.id)) {
continue
}
const date = formatTimestampAsDate(event.created_at)
if (
!newMessagesSeen &&
adjustedAfter &&
2026-07-28 16:16:26 +00:00
event.pubkey !== $user.pubkey &&
event.created_at > adjustedAfter &&
event.created_at < newMessagesBefore
) {
elements.push({type: "new-messages", id: "new-messages"})
newMessagesSeen = true
}
if (date !== previousDate) {
elements.push({type: "date", value: date, id: date, showPubkey: false})
}
const showPubkey =
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
previousKind === addMemberKind
elements.push({
id: event.id,
type: "note",
value: event,
showPubkey,
})
previousDate = date
previousKind = event.kind
previousPubkey = event.pubkey
previousCreatedAt = event.created_at
seen.add(event.id)
}
}
elements.reverse()
return elements
})
2026-08-30 00:42:07 +00:00
// Newer messages are only worth waiting for when the window stops short of the present, which
// only happens after jumping into history — anything published from here on arrives through the
// repository rather than through a forward walk. And with no messages between them the two
// loaders would sit against each other, so this one yields while the other is still running.
const loadingForward = $derived(
!isNaN(at) && $newer?.status !== "exhausted" && !(elements.length === 0 && loadingBackward),
)
$effect(() => {
if (elements.length > 0 && !isUserScrolling) {
requestAnimationFrame(manageScrollPosition)
}
})
2026-08-31 15:47:55 +00:00
// Content can arrive mid-scroll too, so this runs whether or not the reader is moving
$effect(() => {
if (elements.length > 0) {
const frame = requestAnimationFrame(keepPinned)
return () => cancelAnimationFrame(frame)
}
})
// Bound here rather than in the markup: these watch for the reader taking over, they don't make
// the transcript a control, and declaring them as handlers would claim it is one.
$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 = () => {
cleanup?.()
const feed = makeFeed({
relays: [url],
at: at || now(),
filters: [
h ? {kinds: [MESSAGE, addMemberKind], "#h": [h]} : {kinds: [MESSAGE, addMemberKind]},
],
onEvent: context.add,
})
2026-08-30 00:42:07 +00:00
// The container is reversed, so scrolling away from its origin reaches older messages and
// sitting at the origin is the newest end.
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)
})
const unsubscribeActive = documentActive.subscribe(onActiveChange)
return () => {
cleanup?.()
unsubscribeActive()
cancelAnimationFrame(frame)
}
})
</script>
<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>
{#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}
2026-08-30 00:42:07 +00:00
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
2026-08-31 15:07:09 +00:00
<VirtualList
items={elements}
getKey={getElementKey}
container={element}
bind:controller={virtualList}>
{#snippet row({type, id, value, showPubkey})}
{#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}
2026-08-31 15:07:09 +00:00
{@const event = value as TrustedEvent}
{#if event.kind === addMemberKind}
<RoomItemAddMember {url} {event} />
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{context}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/if}
2026-08-31 15:07:09 +00:00
{/snippet}
</VirtualList>
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
2026-08-30 00:42:07 +00:00
<Spinner loading>Looking for messages...</Spinner>
{:else if reachedStartOfHistory}
2026-08-29 17:22:57 +00:00
End of message history
{/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 class="button button-neutral button-circle" onclick={scrollToBottom}>
<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>
{#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>
2026-08-27 23:54:30 +00:00
{: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>
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>