flotilla/src/app/components/RoomChat.svelte

749 lines
24 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"
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"
import {
CallState,
callTargetRoom,
callState,
VideoCallLayout,
videoCallLayout,
deriveIsCallActiveElsewhere,
} 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"
import {makeFeed} 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()
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,
)
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,
)
const roomPath = h ? makeRoomPath(url, h) : undefined
const closeChat = () => videoCallLayout.set(VideoCallLayout.Video)
$effect(() => {
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
if (roomPath && voiceConnectedHere && !videoCallChatHidden) {
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))
let wasConnectedHere = $state(false)
$effect(() => {
if (!voiceConnectedHere) {
if ($callState !== CallState.Connected) {
videoCallLayout.set(VideoCallLayout.Chat)
}
wasConnectedHere = false
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.
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()
}
}
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
}
}
if (!userHasScrolled && !isNaN(at)) {
const targetEvent = $events.find(event => event.created_at >= at)
if (targetEvent) {
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
target.scrollIntoView({block: "center"})
}
}
}
}
const onScroll = () => {
if (!isProgrammaticScroll) {
userHasScrolled = true
isUserScrolling = true
clearIsUserScrolling()
manageScrollPosition()
}
isProgrammaticScroll = false
}
const scrollToNewMessages = () =>
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
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)
let userHasScrolled = $state(false)
let isProgrammaticScroll = $state(false)
let isUserScrolling = $state(false)
let loadingBackward = $state(true)
let loadingForward = $state(true)
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-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
})
$effect(() => {
if (elements.length > 0 && !isUserScrolling) {
requestAnimationFrame(manageScrollPosition)
}
})
const start = () => {
cleanup?.()
const feed = makeFeed({
relays: [url],
at: at || now(),
element: element!,
filters: [
h ? {kinds: [MESSAGE, addMemberKind], "#h": [h]} : {kinds: [MESSAGE, addMemberKind]},
],
onBackwardExhausted: () => {
loadingBackward = false
},
onForwardExhausted: () => {
loadingForward = false
},
})
events = feed.events
cleanup = 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(
"flex min-h-0 flex-1 flex-col",
isOverlayChat && "max-md:relative",
// Desktop: chat is a real flex sibling beside the video pane rather than an
// absolute overlay on top of it, so opening/closing it actually resizes the
// video pane instead of just floating a card over it — see the sidebar's
// width transition below for why this fixes controls jumping on toggle.
voiceConnectedHere && "md:flex-row",
)}>
{#if h && voiceConnectedHere}
<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")} />
{/if}
<div
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.
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",
)}>
{#if isOverlayChat}
<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}
<!-- 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}
<div
bind:this={element}
onscroll={onScroll}
class={cx(
"room__content scroll-container",
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
)}>
{#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}
{#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
{id}
class={cx("flex items-center py-2 text-xs transition-colors", {
"opacity-0": showFixedNewMessages,
})}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === addMemberKind}
<RoomItemAddMember {url} {event} />
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
{/if}
<div class="h-screen"></div>
</div>
<div
class={cx(
// no explicit flex-row: it's flex's default direction, and Tailwind emits
// .flex-col before .flex-row in the compiled sheet, so having both classes
// present at once (base flex-row + conditional flex-col below) let flex-row
// always win — the compose row and call controls never actually stacked.
"room__compose flex items-center gap-1 px-2",
showMobileVideoPanel && "max-md:hidden",
// the connected bar (mic/camera/screenshare/settings/chat/leave) is too wide
// to sit beside the compose input on any screen size — give it its own row.
// gap-2 only applies here (the inline row stays flush with no gap).
voiceConnectedHere && "flex-col items-stretch gap-2",
)}>
<div class="room__compose-inner min-w-0 flex-1">
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $room?.meta?.isRestricted() && $membershipStatus !== MembershipStatus.Granted}
<div class="card m-4 flex flex-row items-center justify-between px-4 py-3">
<p class="opacity-75">Only members are allowed to post to this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
{#if joining}
<Spinner size="sm" />
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
</div>
{:else}
<div>
{#if parent}
<RoomComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if sharedEvent}
<RoomComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key initialValues}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
{initialValues}
bind:this={compose} />
{/key}
{/if}
</div>
{#if h}
<div
class={cx(
"hide-on-keyboard flex flex-shrink-0 items-center justify-center py-2",
// CallControlBar's connected pill is itself md:hidden here (a wider copy
// floats over the video instead — see VideoCallContent.svelte), so once
// this row actually stacks (flex-col, above) this wrapper has nothing to
// show on desktop and was otherwise left as an empty padded bar under
// the compose box. voiceConnectedHere is exactly the state where the
// pill is connected, so hiding it here is safe for the join/joining
// states, which don't hit this flex-col branch at all.
voiceConnectedHere && "md:hidden",
)}>
<CallControlBar {url} {h} hideConnectedOnDesktop />
</div>
{/if}
</div>
</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}