2026-07-27 21:11:46 +00:00
|
|
|
<script lang="ts">
|
2026-09-18 17:07:25 +00:00
|
|
|
import {onDestroy, onMount, untrack} from "svelte"
|
2026-07-27 21:11:46 +00:00
|
|
|
import {readable} from "svelte/store"
|
|
|
|
|
import {page} from "$app/stores"
|
2026-09-08 17:23:22 +00:00
|
|
|
import {navigate} from "@app/modal"
|
2026-07-27 21:11:46 +00:00
|
|
|
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"
|
2026-07-27 21:11:46 +00:00
|
|
|
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"
|
2026-07-27 21:11:46 +00:00
|
|
|
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"
|
2026-08-10 18:14:46 +00:00
|
|
|
import Close from "@assets/icons/close.svg?dataurl"
|
2026-07-27 21:11:46 +00:00
|
|
|
import {fade, fly} from "@lib/transition"
|
2026-08-10 16:16:30 +00:00
|
|
|
import {documentActive} from "@lib/html"
|
2026-07-27 21:11:46 +00:00
|
|
|
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"
|
2026-07-27 21:11:46 +00:00
|
|
|
import RoomCompose from "@app/components/RoomCompose.svelte"
|
2026-09-09 20:27:55 +00:00
|
|
|
import ComposeEdit from "@app/components/ComposeEdit.svelte"
|
|
|
|
|
import ComposeParent from "@app/components/ComposeParent.svelte"
|
2026-07-27 21:11:46 +00:00
|
|
|
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"
|
2026-08-13 16:01:08 +00:00
|
|
|
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"
|
2026-07-27 21:11:46 +00:00
|
|
|
import {userSettingsValues} from "@app/settings"
|
2026-09-18 17:41:46 +00:00
|
|
|
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
|
|
|
|
import {pageLoading} from "@app/loading"
|
2026-08-13 16:01:08 +00:00
|
|
|
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"
|
2026-07-27 21:11:46 +00:00
|
|
|
import {pushToast} from "@app/toast"
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
url: string
|
|
|
|
|
h?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {url, h}: Props = $props()
|
|
|
|
|
|
2026-08-29 16:19:01 +00:00
|
|
|
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)
|
2026-07-27 21:11:46 +00:00
|
|
|
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
|
|
|
|
|
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
|
|
|
|
|
|
2026-08-13 16:01:08 +00:00
|
|
|
const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h)
|
|
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
const voiceConnectedHere = $derived(
|
2026-08-13 16:01:08 +00:00
|
|
|
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
|
2026-07-27 21:11:46 +00:00
|
|
|
)
|
|
|
|
|
|
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)
|
2026-07-27 21:11:46 +00:00
|
|
|
|
2026-08-10 18:14:46 +00:00
|
|
|
const roomPath = h ? makeRoomPath(url, h) : undefined
|
|
|
|
|
|
|
|
|
|
const closeChat = () => videoCallLayout.set(VideoCallLayout.Video)
|
|
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
$effect(() => {
|
2026-08-27 23:54:30 +00:00
|
|
|
deferredRoomPath.set(callChatHidden ? roomPath : undefined)
|
|
|
|
|
if (roomPath && callChatOpen) {
|
2026-07-27 21:11:46 +00:00
|
|
|
setChecked(roomPath)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onDestroy(() => deferredRoomPath.set(undefined))
|
|
|
|
|
|
2026-08-10 18:14:46 +00:00
|
|
|
let wasConnectedHere = $state(false)
|
2026-07-27 21:11:46 +00:00
|
|
|
|
|
|
|
|
$effect(() => {
|
2026-08-10 18:14:46 +00:00
|
|
|
if (!voiceConnectedHere) {
|
|
|
|
|
if ($callState !== CallState.Connected) {
|
|
|
|
|
videoCallLayout.set(VideoCallLayout.Chat)
|
|
|
|
|
}
|
|
|
|
|
wasConnectedHere = false
|
2026-07-27 21:11:46 +00:00
|
|
|
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.
|
2026-08-10 18:14:46 +00:00
|
|
|
if (!wasConnectedHere) {
|
2026-07-27 21:11:46 +00:00
|
|
|
videoCallLayout.set(VideoCallLayout.Video)
|
2026-08-10 18:14:46 +00:00
|
|
|
wasConnectedHere = true
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const shouldProtect = $relays.hasNip(url, 70)
|
2026-07-27 21:11:46 +00:00
|
|
|
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))
|
2026-07-27 21:11:46 +00:00
|
|
|
|
|
|
|
|
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
|
2026-09-10 00:45:54 +00:00
|
|
|
start(at || now())
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
joining = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const leave = async () => {
|
|
|
|
|
if (h) {
|
|
|
|
|
leaving = true
|
2026-08-21 15:31:53 +00:00
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
try {
|
2026-08-21 15:31:53 +00:00
|
|
|
const message = await leaveRoom(url, h)
|
2026-07-27 21:11:46 +00:00
|
|
|
|
2026-08-21 15:31:53 +00:00
|
|
|
if (message) {
|
2026-07-27 21:11:46 +00:00
|
|
|
pushToast({theme: "error", message})
|
2026-08-21 15:31:53 +00:00
|
|
|
} else {
|
|
|
|
|
pushToast({message: "You have left the room."})
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
} 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) {
|
2026-07-27 21:11:46 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-07-28 16:16:26 +00:00
|
|
|
const protect = await shouldProtect
|
|
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
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])
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-14 23:34:45 +00:00
|
|
|
// A share is a quote rather than a reply, so it goes in the content directly and
|
|
|
|
|
// 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))
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (parent) {
|
2026-09-14 23:34:45 +00:00
|
|
|
eventWriter.setParent(parent)
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const thunk = $thunks.publish({
|
2026-07-27 21:11:46 +00:00
|
|
|
relays: [url],
|
2026-09-14 23:34:45 +00:00
|
|
|
event: stamp(await eventWriter.renderTemplate(), eventToEdit?.created_at),
|
2026-07-27 21:11:46 +00:00
|
|
|
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
|
|
|
// 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
|
|
|
|
|
|
2026-09-21 17:56:28 +00:00
|
|
|
// Messages arriving at the origin push the pinned row away, and the browser does not compensate.
|
2026-09-21 17:53:18 +00:00
|
|
|
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) {
|
2026-09-21 17:53:18 +00:00
|
|
|
const origin = Math.abs(element!.scrollTop)
|
2026-08-31 15:47:55 +00:00
|
|
|
|
2026-09-21 17:56:28 +00:00
|
|
|
// A pin clamped at the origin belongs at the live end once the forward walk catches up.
|
2026-09-21 17:53:18 +00:00
|
|
|
if (pinned.clamped && caughtUp && topOf(target) > origin) {
|
|
|
|
|
release()
|
2026-09-21 17:56:28 +00:00
|
|
|
|
|
|
|
|
// 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})
|
|
|
|
|
}
|
2026-09-21 17:53:18 +00:00
|
|
|
} 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 15:57:39 +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.
|
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) {
|
2026-09-21 17:53:18 +00:00
|
|
|
// A scroll that ended at the origin is one the browser clamped there, so the offset
|
|
|
|
|
// below is as close to centred as the row could get rather than where it was put.
|
|
|
|
|
pinned = {id, top: topOf(target), clamped: Math.abs(element!.scrollTop) < 1}
|
2026-08-31 15:47:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jumpSettled = true
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
const manageScrollPosition = () => {
|
2026-09-08 15:03:43 +00:00
|
|
|
scrolledUp = Math.abs(element?.scrollTop || 0) > 1500
|
2026-07-27 21:11:46 +00:00
|
|
|
|
|
|
|
|
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)
|
2026-07-27 21:11:46 +00:00
|
|
|
|
|
|
|
|
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
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"})
|
2026-07-27 21:11:46 +00:00
|
|
|
|
2026-09-10 00:45:54 +00:00
|
|
|
// While the window stops short, dropping the anchor only takes the button away: where the reader
|
|
|
|
|
// lands is then whatever the list settles on as it re-windows and the forward walk catches up,
|
|
|
|
|
// which is not reliably the live end. Anchoring a fresh feed on the present puts it there.
|
|
|
|
|
const scrollToBottom = async () => {
|
|
|
|
|
const anchored = !isNaN(at)
|
|
|
|
|
|
|
|
|
|
if (anchored) {
|
|
|
|
|
release()
|
|
|
|
|
await navigate($page.url.pathname, {replaceState: true})
|
|
|
|
|
start(now())
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
2026-09-10 00:45:54 +00:00
|
|
|
|
|
|
|
|
element?.scrollTo({top: 0, behavior: anchored ? "auto" : "smooth"})
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-10 16:16:30 +00:00
|
|
|
// 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) {
|
2026-07-27 21:11:46 +00:00
|
|
|
lastVisibleAt = now()
|
2026-07-28 16:16:26 +00:00
|
|
|
} else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) {
|
2026-07-27 21:11:46 +00:00
|
|
|
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
|
2026-09-21 17:53:18 +00:00
|
|
|
let pinned: Maybe<{id: string; top: number; clamped: boolean}>
|
2026-09-18 17:07:25 +00:00
|
|
|
let feedAnchor: Maybe<number>
|
2026-07-27 21:11:46 +00:00
|
|
|
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()
|
2026-07-27 21:11:46 +00:00
|
|
|
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)
|
2026-09-08 15:03:43 +00:00
|
|
|
let scrolledUp = $state(false)
|
2026-07-27 21:11:46 +00:00
|
|
|
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
|
|
|
const reachedStartOfHistory = $derived($older?.status === "exhausted")
|
|
|
|
|
|
2026-09-18 17:41:46 +00:00
|
|
|
// A room paged from an anchor walks in both directions at once, and neither walk is worth a
|
|
|
|
|
// loader of its own in the transcript — the page bar carries one for the pair of them.
|
|
|
|
|
$effect(() => {
|
|
|
|
|
pageLoading.set(isFeedLoading($older) || isFeedLoading($newer))
|
|
|
|
|
|
|
|
|
|
return () => pageLoading.set(false)
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-07-27 21:11:46 +00:00
|
|
|
|
2026-09-08 15:03:43 +00:00
|
|
|
// The window only stops short of the present after jumping into history — anything published
|
|
|
|
|
// from here on arrives through the repository rather than through a forward walk.
|
|
|
|
|
const windowStopsShort = $derived(!isNaN(at) && $newer?.status !== "exhausted")
|
|
|
|
|
|
|
|
|
|
// While the window stops short, the bottom of the container is not the bottom of the
|
|
|
|
|
// conversation, so the button is the way back to the live end rather than a scroll — which is
|
|
|
|
|
// why it clears `at` instead of scrolling. Once the two are the same place, scroll position is
|
|
|
|
|
// the whole answer.
|
|
|
|
|
const showScrollButton = $derived(scrolledUp || windowStopsShort)
|
2026-08-30 00:42:07 +00:00
|
|
|
|
2026-09-18 17:07:25 +00:00
|
|
|
// A search result or a notification jumping into the room already on screen changes the url
|
|
|
|
|
// without re-creating this component, so the feed built on the old anchor has to be rebuilt
|
|
|
|
|
// on the new one. `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))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
$effect(() => {
|
|
|
|
|
if (elements.length > 0 && !isUserScrolling) {
|
|
|
|
|
requestAnimationFrame(manageScrollPosition)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-21 17:53:18 +00:00
|
|
|
// Content can arrive mid-scroll too, so this runs whether or not the reader is moving. The
|
|
|
|
|
// forward walk reaching the present is the other thing a pin watches for, and it can land after
|
|
|
|
|
// the last message does, so it is read here where the effect sees it change.
|
2026-08-31 15:47:55 +00:00
|
|
|
$effect(() => {
|
2026-09-21 17:53:18 +00:00
|
|
|
const caughtUp = !windowStopsShort
|
|
|
|
|
|
2026-08-31 15:47:55 +00:00
|
|
|
if (elements.length > 0) {
|
2026-09-21 17:53:18 +00:00
|
|
|
const frame = requestAnimationFrame(() => keepPinned(caughtUp))
|
2026-08-31 15:47:55 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-10 00:45:54 +00:00
|
|
|
const start = (anchor: number) => {
|
2026-07-27 21:11:46 +00:00
|
|
|
cleanup?.()
|
|
|
|
|
|
2026-09-18 17:07:25 +00:00
|
|
|
feedAnchor = anchor
|
|
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
const feed = makeFeed({
|
|
|
|
|
relays: [url],
|
2026-09-10 00:45:54 +00:00
|
|
|
at: anchor,
|
2026-07-27 21:11:46 +00:00
|
|
|
filters: [
|
|
|
|
|
h ? {kinds: [MESSAGE, addMemberKind], "#h": [h]} : {kinds: [MESSAGE, addMemberKind]},
|
|
|
|
|
],
|
2026-08-29 16:19:01 +00:00
|
|
|
onEvent: context.add,
|
2026-07-27 21:11:46 +00:00
|
|
|
})
|
|
|
|
|
|
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})
|
|
|
|
|
|
2026-07-27 21:11:46 +00:00
|
|
|
events = feed.events
|
2026-08-30 00:42:07 +00:00
|
|
|
cleanup = () => {
|
|
|
|
|
older?.stop()
|
|
|
|
|
newer?.stop()
|
|
|
|
|
feed.cleanup()
|
|
|
|
|
}
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-27 21:11:46 +00:00
|
|
|
|
|
|
|
|
const onEditEvent = (event: TrustedEvent) => {
|
|
|
|
|
clearParent()
|
|
|
|
|
clearShare()
|
|
|
|
|
eventToEdit = event
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
|
|
|
|
|
|
|
|
|
|
onMount(() => {
|
2026-08-10 22:09:41 +00:00
|
|
|
// Defer rendering until navigation finishes
|
|
|
|
|
let frame = requestAnimationFrame(() => {
|
2026-09-10 00:45:54 +00:00
|
|
|
frame = requestAnimationFrame(() => start(at || now()))
|
2026-08-10 22:09:41 +00:00
|
|
|
})
|
2026-07-27 21:11:46 +00:00
|
|
|
|
2026-08-10 16:16:30 +00:00
|
|
|
const unsubscribeActive = documentActive.subscribe(onActiveChange)
|
2026-07-27 21:11:46 +00:00
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cleanup?.()
|
2026-08-10 16:16:30 +00:00
|
|
|
unsubscribeActive()
|
2026-08-10 22:09:41 +00:00
|
|
|
cancelAnimationFrame(frame)
|
2026-07-27 21:11:46 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
</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}
|
|
|
|
|
|
2026-08-27 23:06:10 +00:00
|
|
|
<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",
|
2026-08-27 23:06:10 +00:00
|
|
|
)}>
|
2026-07-27 21:11:46 +00:00
|
|
|
{#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>
|
2026-07-27 21:11:46 +00:00
|
|
|
{/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. -->
|
2026-07-27 21:11:46 +00:00
|
|
|
<div
|
2026-08-27 23:54:30 +00:00
|
|
|
inert={callChatHidden}
|
2026-07-27 21:11:46 +00:00
|
|
|
class={cx(
|
2026-08-27 23:54:30 +00:00
|
|
|
"relative flex min-h-0 min-w-0 flex-1 flex-col",
|
2026-08-27 23:06:10 +00:00
|
|
|
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-07-27 21:11:46 +00:00
|
|
|
)}>
|
2026-08-27 23:54:30 +00:00
|
|
|
{#if callChatOpen}
|
2026-08-10 18:14:46 +00:00
|
|
|
<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>
|
2026-07-27 21:11:46 +00:00
|
|
|
{/if}
|
|
|
|
|
|
2026-08-27 23:54:30 +00:00
|
|
|
{#if h}
|
|
|
|
|
<RoomPinnedMessages {url} {h} />
|
|
|
|
|
{/if}
|
2026-08-27 23:06:10 +00:00
|
|
|
|
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,
|
|
|
|
|
})}>
|
2026-08-27 23:06:10 +00:00
|
|
|
{#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")}
|
2026-08-27 23:06:10 +00:00
|
|
|
</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} />
|
2026-08-27 23:06:10 +00:00
|
|
|
{:else}
|
2026-08-31 15:57:39 +00:00
|
|
|
<RoomItem
|
|
|
|
|
{url}
|
|
|
|
|
{replyTo}
|
|
|
|
|
{context}
|
|
|
|
|
event={item.value}
|
|
|
|
|
showPubkey={item.showPubkey}
|
|
|
|
|
canEdit={canEditEvent}
|
|
|
|
|
onEdit={onEditEvent} />
|
2026-08-27 23:06:10 +00:00
|
|
|
{/if}
|
2026-08-31 15:07:09 +00:00
|
|
|
{/snippet}
|
|
|
|
|
</VirtualList>
|
2026-08-27 23:06:10 +00:00
|
|
|
<p class="flex h-10 items-center justify-center py-20">
|
2026-09-18 17:41:46 +00:00
|
|
|
{#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>
|
2026-07-27 21:11:46 +00:00
|
|
|
{/if}
|
|
|
|
|
</p>
|
|
|
|
|
{/if}
|
2026-08-27 23:06:10 +00:00
|
|
|
<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">
|
2026-09-08 15:03:43 +00:00
|
|
|
<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>
|
2026-08-27 23:06:10 +00:00
|
|
|
</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")}
|
2026-07-27 21:11:46 +00:00
|
|
|
</div>
|
2026-08-27 23:54:30 +00:00
|
|
|
{:else}
|
2026-09-09 20:27:55 +00:00
|
|
|
<div class="flex flex-col gap-px">
|
2026-08-27 23:54:30 +00:00
|
|
|
{#if parent}
|
2026-09-09 20:27:55 +00:00
|
|
|
<ComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
|
2026-08-27 23:54:30 +00:00
|
|
|
{/if}
|
|
|
|
|
{#if sharedEvent}
|
2026-09-09 20:27:55 +00:00
|
|
|
<ComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
|
2026-08-27 23:54:30 +00:00
|
|
|
{/if}
|
|
|
|
|
{#if eventToEdit}
|
2026-09-09 20:27:55 +00:00
|
|
|
<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}
|
2026-07-27 21:11:46 +00:00
|
|
|
{/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}
|
2026-07-27 21:11:46 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|