Split RoomChat into RoomTranscript, RoomMembershipButton and RoomCallPane so each owns its own state

This commit is contained in:
Jon Staab 2026-09-25 16:28:34 -07:00
parent 9a49aa6638
commit 945a9649e6
4 changed files with 539 additions and 463 deletions

View file

@ -0,0 +1,27 @@
<script lang="ts">
import cx from "classnames"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte"
import {VideoCallLayout, videoCallLayout} from "@app/call"
type Props = {
url: string
h: string
}
const {url, h}: Props = $props()
// Only mounted while the call is connected here, so the layout alone says whether chat is open.
const chatOpen = $derived($videoCallLayout === VideoCallLayout.Split)
</script>
<!-- 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", chatOpen ? "xl:flex-1" : "flex-1")}>
<VideoCallContent {url} {h} class={cx(chatOpen && "max-xl:hidden")} />
<div class="hide-on-keyboard flex shrink-0 items-center justify-center pb-2">
<CallControlBar {url} {h} />
</div>
</div>

View file

@ -1,52 +1,35 @@
<script lang="ts"> <script lang="ts">
import {onDestroy, onMount, untrack} from "svelte" import {onDestroy} from "svelte"
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {page} from "$app/stores"
import {navigate} from "@app/modal"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {debounce} from "throttle-debounce"
import cx from "classnames" import cx from "classnames"
import {now, ago, spec, MINUTE} from "@welshman/lib" import {ago, MINUTE} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {stamp, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util" import {stamp} from "@welshman/util"
import {Message} from "@welshman/domain" import {Message} from "@welshman/domain"
import {MembershipStatus} from "@welshman/app" 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 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 Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import VirtualList from "@lib/components/VirtualList.svelte"
import type {VirtualListController} from "@lib/components/VirtualList.svelte"
import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte" import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte"
import RoomItem from "@app/components/RoomItem.svelte" import RoomCallPane from "@app/components/RoomCallPane.svelte"
import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte" import RoomMembershipButton from "@app/components/RoomMembershipButton.svelte"
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte" import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
import RoomTranscript from "@app/components/RoomTranscript.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte" import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user, writer} from "@app/core" import {deletes, relays, rooms, thunks, user, writer} from "@app/core"
import {joinRoom, leaveRoom} from "@app/access"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call" import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
import { import {
RoomType, RoomType,
deriveUserRoomMembershipStatus, deriveUserRoomMembershipStatus,
getRoomType, getRoomType,
groupRoomMessages,
prependParent, prependParent,
toSpace, toSpace,
} from "@app/rooms" } from "@app/rooms"
import {loadSendDelay} from "@app/settings" import {loadSendDelay} from "@app/settings"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {deferredRoomPath, setChecked} from "@app/notifications"
import {pageLoading} from "@app/loading" import {makeRoomPath} from "@app/routes"
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
import {getPermalinkTarget, highlightedEvent, makeRoomPath} from "@app/routes"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -56,12 +39,7 @@
const {url, h}: Props = $props() const {url, h}: Props = $props()
const context = makeFeedContext({relays: [url]})
onDestroy(context.cleanup)
const room = h ? $rooms.forRoom(url, h) : readable(undefined) 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 isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h) const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h)
@ -109,46 +87,9 @@
const membershipStatus = h const membershipStatus = h
? deriveUserRoomMembershipStatus(url, h) ? deriveUserRoomMembershipStatus(url, h)
: readable(MembershipStatus.Granted) : readable(MembershipStatus.Granted)
const at = $derived(parseInt($page.url.searchParams.get("at")!))
const inviteCode = $derived($page.url.searchParams.get("code") || "")
const target = $derived(getPermalinkTarget($page.url))
const join = async () => {
if (h) {
joining = true
try {
const message = await joinRoom(url, h, inviteCode)
if (message) {
pushToast({theme: "error", message})
} else {
// Restart the feed now that we're a member // Restart the feed now that we're a member
start(at || now()) const onJoin = () => transcript?.restart()
}
} finally {
joining = false
}
}
}
const leave = async () => {
if (h) {
leaving = true
try {
const message = await leaveRoom(url, h)
if (message) {
pushToast({theme: "error", message})
} else {
pushToast({message: "You have left the room."})
}
} finally {
leaving = false
}
}
}
const replyTo = (event: TrustedEvent) => composeArea?.reply(event) const replyTo = (event: TrustedEvent) => composeArea?.reply(event)
@ -200,328 +141,23 @@
} }
} }
const getElementKey = (element: {id: string}) => element.id
// 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 arriving at the origin push the pinned row away, and the browser does not compensate.
const keepPinned = (caughtUp: boolean) => {
const target = pinned && element?.querySelector(`[data-event="${pinned.id}"]`)
if (target instanceof HTMLElement && pinned) {
const origin = Math.abs(element!.scrollTop)
// 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})
}
}
}
}
// 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.
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) {
highlightedEvent.set(id)
}
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}
}
}
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
}
}
if (!released && !pinned && !isNaN(at)) {
// 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) {
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
}
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()
} 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 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)
let virtualList: Maybe<VirtualListController> = $state()
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let newer: Maybe<ReturnType<typeof makeScrollLoader>> = $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 composeArea: ComposeArea | undefined = $state()
// A link into history renders the newest messages first and only then scrolls.
const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
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)
})
const clearIsUserScrolling = debounce(150, () => {
isUserScrolling = false
})
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)
// `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.
$effect(() => {
const caughtUp = !windowStopsShort
if (elements.length > 0) {
const frame = requestAnimationFrame(() => keepPinned(caughtUp))
return () => cancelAnimationFrame(frame)
}
})
// Declaring these as handlers would claim the transcript is a control.
$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.
older = makeScrollLoader(element!, feed.loadOlder)
newer = makeScrollLoader(element!, feed.loadNewer, {reverse: true})
events = feed.events
cleanup = () => {
older?.stop()
newer?.stop()
feed.cleanup()
}
}
const canEditEvent = (event: TrustedEvent) => const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $user.pubkey && event.created_at >= ago(5, MINUTE) event.pubkey === $user.pubkey && event.created_at >= ago(5, MINUTE)
const onEditEvent = (event: TrustedEvent) => composeArea?.edit(event) const onEditEvent = (event: TrustedEvent) => composeArea?.edit(event)
onMount(() => { let events: Readable<TrustedEvent[]> = $state(readable([]))
// Defer rendering until navigation finishes let transcript: RoomTranscript | undefined = $state()
let frame = requestAnimationFrame(() => { let composeArea: ComposeArea | undefined = $state()
frame = requestAnimationFrame(() => start(at || now()))
})
const unsubscribeActive = documentActive.subscribe(onActiveChange)
return () => {
cleanup?.()
unsubscribeActive()
cancelAnimationFrame(frame)
}
})
</script> </script>
{#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 <div
class={cx( class={cx(
"room flex min-h-0 min-w-0 flex-1 flex-col", "room flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && "bg-surface xl:flex-row xl:overflow-hidden", voiceConnectedHere && "bg-surface xl:flex-row xl:overflow-hidden",
)}> )}>
{#if h && voiceConnectedHere} {#if h && voiceConnectedHere}
<!-- The controls belong to the video, so they share its column and the chat panel <RoomCallPane {url} {h} />
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} {/if}
<!-- Closed at xl, the negative margin takes the panel out of the flex line without <!-- Closed at xl, the negative margin takes the panel out of the flex line without
@ -550,98 +186,23 @@
<RoomPinnedMessages {url} {h} /> <RoomPinnedMessages {url} {h} />
{/if} {/if}
<div class="relative flex min-h-0 flex-1 flex-col"> <RoomTranscript
<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>
{@render membershipButton("Join Room")}
</div>
</div>
{:else}
<VirtualList
items={elements}
getKey={getElementKey}
container={element}
bind:controller={virtualList}>
{#snippet row(item)}
{#if item.type === "new-messages"}
<div
id={item.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 item.type === "date"}
<Divider>{item.value}</Divider>
{:else if item.value.kind === addMemberKind}
<RoomItemAddMember {url} event={item.value} />
{:else}
<RoomItem
{url} {url}
{h}
{replyTo} {replyTo}
{context}
event={item.value}
showPubkey={item.showPubkey}
canEdit={canEditEvent} canEdit={canEditEvent}
onEdit={onEditEvent} /> onEdit={onEditEvent}
{/if} bind:events
{/snippet} bind:this={transcript} />
</VirtualList>
<p class="flex h-10 items-center justify-center py-20">
{#if reachedStartOfHistory}
End of message history
{:else}
<Spinner loading>Looking for messages...</Spinner>
{/if}
</p>
{/if}
<div class="h-screen"></div>
</div>
{#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}>
<Icon icon={AltArrowDown} />
</Button>
</div>
{/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 flex items-center gap-1 px-2">
<div class="room__compose-inner min-w-0 flex-1"> <div class="room__compose-inner min-w-0 flex-1">
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted} {#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<!-- pass --> <!-- pass -->
{:else if $room?.meta?.isRestricted() && $membershipStatus !== MembershipStatus.Granted} {:else if h && $room?.meta?.isRestricted() && $membershipStatus !== MembershipStatus.Granted}
<div class="card m-4 flex flex-row items-center justify-between px-4 py-3"> <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> <p class="opacity-75">Only members are allowed to post to this room.</p>
{@render membershipButton("Ask to Join")} <RoomMembershipButton {url} {h} label="Ask to Join" {onJoin} />
</div> </div>
{:else} {:else}
<ComposeArea <ComposeArea

View file

@ -0,0 +1,75 @@
<script lang="ts">
import {page} from "$app/stores"
import {MembershipStatus} from "@welshman/app"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import {joinRoom, leaveRoom} from "@app/access"
import {deriveUserRoomMembershipStatus} from "@app/rooms"
import {pushToast} from "@app/toast"
type Props = {
url: string
h: string
label: string
onJoin: () => void
}
const {url, h, label, onJoin}: Props = $props()
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const inviteCode = $derived($page.url.searchParams.get("code") || "")
const join = async () => {
joining = true
try {
const message = await joinRoom(url, h, inviteCode)
if (message) {
pushToast({theme: "error", message})
} else {
onJoin()
}
} finally {
joining = false
}
}
const leave = async () => {
leaving = true
try {
const message = await leaveRoom(url, h)
if (message) {
pushToast({theme: "error", message})
} else {
pushToast({message: "You have left the room."})
}
} finally {
leaving = false
}
}
let joining = $state(false)
let leaving = $state(false)
</script>
{#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}

View file

@ -0,0 +1,413 @@
<script lang="ts">
import {onDestroy, onMount, untrack} from "svelte"
import type {ComponentProps} from "svelte"
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {page} from "$app/stores"
import {debounce} from "throttle-debounce"
import cx from "classnames"
import {now, spec} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
import {MembershipStatus} from "@welshman/app"
import AltArrowDown from "@assets/icons/alt-arrow-down.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 VirtualList from "@lib/components/VirtualList.svelte"
import type {VirtualListController} from "@lib/components/VirtualList.svelte"
import {navigate} from "@app/modal"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte"
import RoomMembershipButton from "@app/components/RoomMembershipButton.svelte"
import {rooms, user} from "@app/core"
import {deriveUserRoomMembershipStatus, groupRoomMessages} from "@app/rooms"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {pageLoading} from "@app/loading"
import {checked} from "@app/notifications"
import {getPermalinkTarget, highlightedEvent} from "@app/routes"
type Props = Pick<ComponentProps<typeof RoomItem>, "url" | "replyTo" | "canEdit" | "onEdit"> & {
h?: string
events?: Readable<TrustedEvent[]>
}
let {url, h, replyTo, canEdit, onEdit, events = $bindable(readable([]))}: Props = $props()
const context = makeFeedContext({relays: [url]})
onDestroy(context.cleanup)
const room = h ? $rooms.forRoom(url, h) : readable(undefined)
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
const membershipStatus = h
? deriveUserRoomMembershipStatus(url, h)
: readable(MembershipStatus.Granted)
const at = $derived(parseInt($page.url.searchParams.get("at")!))
const target = $derived(getPermalinkTarget($page.url))
export const restart = () => start(at || now())
const getElementKey = (element: {id: string}) => element.id
// 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 arriving at the origin push the pinned row away, and the browser does not compensate.
const keepPinned = (caughtUp: boolean) => {
const target = pinned && element?.querySelector(`[data-event="${pinned.id}"]`)
if (target instanceof HTMLElement && pinned) {
const origin = Math.abs(element!.scrollTop)
// 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})
}
}
}
}
// 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.
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) {
highlightedEvent.set(id)
}
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}
}
}
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
}
}
if (!released && !pinned && !isNaN(at)) {
// 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) {
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
}
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()
} else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) {
newMessagesAfter = lastVisibleAt
newMessagesBefore = now()
newMessagesSeen = false
}
}
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)
let virtualList: Maybe<VirtualListController> = $state()
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let newer: Maybe<ReturnType<typeof makeScrollLoader>> = $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
// A link into history renders the newest messages first and only then scrolls.
const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
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)
})
const clearIsUserScrolling = debounce(150, () => {
isUserScrolling = false
})
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)
// `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.
$effect(() => {
const caughtUp = !windowStopsShort
if (elements.length > 0) {
const frame = requestAnimationFrame(() => keepPinned(caughtUp))
return () => cancelAnimationFrame(frame)
}
})
// Declaring these as handlers would claim the transcript is a control.
$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.
older = makeScrollLoader(element!, feed.loadOlder)
newer = makeScrollLoader(element!, feed.loadNewer, {reverse: true})
events = feed.events
cleanup = () => {
older?.stop()
newer?.stop()
feed.cleanup()
}
}
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>
<div class="relative flex min-h-0 flex-1 flex-col">
<div
bind:this={element}
onscroll={onScroll}
class={cx("room__content scroll-container transition-opacity", {
"opacity-0": awaitingJump,
})}>
{#if h && $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>
<RoomMembershipButton {url} {h} label="Join Room" onJoin={restart} />
</div>
</div>
{:else}
<VirtualList
items={elements}
getKey={getElementKey}
container={element}
bind:controller={virtualList}>
{#snippet row(item)}
{#if item.type === "new-messages"}
<div
id={item.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 item.type === "date"}
<Divider>{item.value}</Divider>
{:else if item.value.kind === addMemberKind}
<RoomItemAddMember {url} event={item.value} />
{:else}
<RoomItem
{url}
{replyTo}
{context}
{canEdit}
{onEdit}
event={item.value}
showPubkey={item.showPubkey} />
{/if}
{/snippet}
</VirtualList>
<p class="flex h-10 items-center justify-center py-20">
{#if reachedStartOfHistory}
End of message history
{:else}
<Spinner loading>Looking for messages...</Spinner>
{/if}
</p>
{/if}
<div class="h-screen"></div>
</div>
{#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}>
<Icon icon={AltArrowDown} />
</Button>
</div>
{/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>