Re-anchor room and space chat on a keyed remount only, parse the at param once, and derive the locked-room check in RoomChat
This commit is contained in:
parent
52583f970c
commit
0c160a162d
4 changed files with 29 additions and 43 deletions
|
|
@ -56,7 +56,8 @@ const url = decodeRelay(relay)
|
||||||
|
|
||||||
That is safe because the layouts remount their children when a param changes.
|
That is safe because the layouts remount their children when a param changes.
|
||||||
`spaces/+layout.svelte` keys on `relay`, `spaces/[relay]/+layout.svelte` and
|
`spaces/+layout.svelte` keys on `relay`, `spaces/[relay]/+layout.svelte` and
|
||||||
`chat/+layout.svelte` key on the pathname, `[h]/+layout.svelte` keys on `?at=`, and
|
`chat/+layout.svelte` key on the pathname, `[h]/+layout.svelte` and the space chat page key on
|
||||||
|
`?at=` and `?event=` (which is how a room transcript re-anchors on a permalink), and
|
||||||
`people/[npub]/+layout.svelte` keys on `npub`. A new param-bearing route needs the same `{#key}`,
|
`people/[npub]/+layout.svelte` keys on `npub`. A new param-bearing route needs the same `{#key}`,
|
||||||
or its page has to derive from `$page` instead. Routes read `$page` from the deprecated
|
or its page has to derive from `$page` instead. Routes read `$page` from the deprecated
|
||||||
`$app/stores`; only the two modal modules use `page` from `$app/state`.
|
`$app/stores`; only the two modal modules use `page` from `$app/state`.
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@
|
||||||
const membershipStatus = h
|
const membershipStatus = h
|
||||||
? deriveUserRoomMembershipStatus(url, h)
|
? deriveUserRoomMembershipStatus(url, h)
|
||||||
: readable(MembershipStatus.Granted)
|
: readable(MembershipStatus.Granted)
|
||||||
|
const locked = $derived(
|
||||||
|
$room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted,
|
||||||
|
)
|
||||||
|
|
||||||
// Restart the feed now that we're a member
|
// Restart the feed now that we're a member
|
||||||
const onJoin = () => transcript?.restart()
|
const onJoin = () => transcript?.restart()
|
||||||
|
|
@ -193,6 +196,7 @@
|
||||||
<RoomTranscript
|
<RoomTranscript
|
||||||
{url}
|
{url}
|
||||||
{h}
|
{h}
|
||||||
|
{locked}
|
||||||
{replyTo}
|
{replyTo}
|
||||||
canEdit={canEditEvent}
|
canEdit={canEditEvent}
|
||||||
onEdit={onEditEvent}
|
onEdit={onEditEvent}
|
||||||
|
|
@ -201,7 +205,7 @@
|
||||||
|
|
||||||
<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 locked}
|
||||||
<!-- pass -->
|
<!-- pass -->
|
||||||
{:else if h && $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">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy, onMount, untrack} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} 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 AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
import {fade, fly} from "@lib/transition"
|
import {fade, fly} from "@lib/transition"
|
||||||
import {documentActive} from "@lib/html"
|
import {documentActive} from "@lib/html"
|
||||||
|
|
@ -24,8 +23,8 @@
|
||||||
import RoomItem from "@app/components/RoomItem.svelte"
|
import RoomItem from "@app/components/RoomItem.svelte"
|
||||||
import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte"
|
import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte"
|
||||||
import RoomMembershipButton from "@app/components/RoomMembershipButton.svelte"
|
import RoomMembershipButton from "@app/components/RoomMembershipButton.svelte"
|
||||||
import {rooms, user} from "@app/core"
|
import {user} from "@app/core"
|
||||||
import {deriveUserRoomMembershipStatus, groupRoomMessages} from "@app/rooms"
|
import {groupRoomMessages} from "@app/rooms"
|
||||||
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
||||||
import {pageLoading} from "@app/loading"
|
import {pageLoading} from "@app/loading"
|
||||||
import {checked} from "@app/notifications"
|
import {checked} from "@app/notifications"
|
||||||
|
|
@ -33,24 +32,21 @@
|
||||||
|
|
||||||
type Props = Pick<ComponentProps<typeof RoomItem>, "url" | "replyTo" | "canEdit" | "onEdit"> & {
|
type Props = Pick<ComponentProps<typeof RoomItem>, "url" | "replyTo" | "canEdit" | "onEdit"> & {
|
||||||
h?: string
|
h?: string
|
||||||
|
locked?: boolean
|
||||||
events?: Readable<TrustedEvent[]>
|
events?: Readable<TrustedEvent[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
let {url, h, replyTo, canEdit, onEdit, events = $bindable(readable([]))}: Props = $props()
|
let {url, h, locked, replyTo, canEdit, onEdit, events = $bindable(readable([]))}: Props = $props()
|
||||||
|
|
||||||
const context = makeFeedContext({relays: [url]})
|
const context = makeFeedContext({relays: [url]})
|
||||||
|
|
||||||
onDestroy(context.cleanup)
|
onDestroy(context.cleanup)
|
||||||
|
|
||||||
const room = h ? $rooms.forRoom(url, h) : readable(undefined)
|
|
||||||
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
|
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
|
||||||
const membershipStatus = h
|
const at = $derived(parseInt($page.url.searchParams.get("at") ?? "") || undefined)
|
||||||
? deriveUserRoomMembershipStatus(url, h)
|
|
||||||
: readable(MembershipStatus.Granted)
|
|
||||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
|
||||||
const target = $derived(getPermalinkTarget($page.url))
|
const target = $derived(getPermalinkTarget($page.url))
|
||||||
|
|
||||||
export const restart = () => start(at || now())
|
export const restart = () => start(at ?? now())
|
||||||
|
|
||||||
const getElementKey = (element: {id: string}) => element.id
|
const getElementKey = (element: {id: string}) => element.id
|
||||||
|
|
||||||
|
|
@ -141,7 +137,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!released && !pinned && !isNaN(at)) {
|
if (at && !released && !pinned) {
|
||||||
// The pointer only helps once that message has arrived, so the anchor still carries the jump.
|
// 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 pointedAt = target ? $events.find(spec({id: target})) : undefined
|
||||||
const targetEvent = pointedAt ?? $events.find(event => event.created_at >= at)
|
const targetEvent = pointedAt ?? $events.find(event => event.created_at >= at)
|
||||||
|
|
@ -167,17 +163,13 @@
|
||||||
|
|
||||||
const scrollToNewMessages = () => scrollToRow("new-messages", {behavior: "smooth"})
|
const scrollToNewMessages = () => scrollToRow("new-messages", {behavior: "smooth"})
|
||||||
|
|
||||||
// Anchoring a fresh feed on the present is what puts the reader at the live end.
|
// Dropping the anchor remounts the transcript on the present, which puts the reader at the live end.
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = () => {
|
||||||
const anchored = !isNaN(at)
|
if (at) {
|
||||||
|
navigate($page.url.pathname, {replaceState: true})
|
||||||
if (anchored) {
|
} else {
|
||||||
release()
|
element?.scrollTo({top: 0, behavior: "smooth"})
|
||||||
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.
|
// A tab can be `visible` but unfocused, so `document.hidden` alone doesn't say the room is watched.
|
||||||
|
|
@ -194,7 +186,6 @@
|
||||||
let jumpSettled = $state(false)
|
let jumpSettled = $state(false)
|
||||||
let released = false
|
let released = false
|
||||||
let pinned: Maybe<{id: string; top: number; clamped: boolean}>
|
let pinned: Maybe<{id: string; top: number; clamped: boolean}>
|
||||||
let feedAnchor: Maybe<number>
|
|
||||||
let isProgrammaticScroll = $state(false)
|
let isProgrammaticScroll = $state(false)
|
||||||
let isUserScrolling = $state(false)
|
let isUserScrolling = $state(false)
|
||||||
let virtualList: Maybe<VirtualListController> = $state()
|
let virtualList: Maybe<VirtualListController> = $state()
|
||||||
|
|
@ -210,7 +201,7 @@
|
||||||
let cleanup: () => void
|
let cleanup: () => void
|
||||||
|
|
||||||
// A link into history renders the newest messages first and only then scrolls.
|
// A link into history renders the newest messages first and only then scrolls.
|
||||||
const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
|
const awaitingJump = $derived(Boolean(at) && !jumpSettled)
|
||||||
|
|
||||||
const reachedStartOfHistory = $derived($older?.status === "exhausted")
|
const reachedStartOfHistory = $derived($older?.status === "exhausted")
|
||||||
|
|
||||||
|
|
@ -236,22 +227,11 @@
|
||||||
)
|
)
|
||||||
|
|
||||||
// The window only stops short of the present after jumping into history.
|
// The window only stops short of the present after jumping into history.
|
||||||
const windowStopsShort = $derived(!isNaN(at) && $newer?.status !== "exhausted")
|
const windowStopsShort = $derived(Boolean(at) && $newer?.status !== "exhausted")
|
||||||
|
|
||||||
// While the window stops short, the bottom of the container isn't the bottom of the conversation.
|
// While the window stops short, the bottom of the container isn't the bottom of the conversation.
|
||||||
const showScrollButton = $derived(scrolledUp || windowStopsShort)
|
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(() => {
|
$effect(() => {
|
||||||
if (elements.length > 0 && !isUserScrolling) {
|
if (elements.length > 0 && !isUserScrolling) {
|
||||||
requestAnimationFrame(manageScrollPosition)
|
requestAnimationFrame(manageScrollPosition)
|
||||||
|
|
@ -289,8 +269,6 @@
|
||||||
const start = (anchor: number) => {
|
const start = (anchor: number) => {
|
||||||
cleanup?.()
|
cleanup?.()
|
||||||
|
|
||||||
feedAnchor = anchor
|
|
||||||
|
|
||||||
const feed = makeFeed({
|
const feed = makeFeed({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
at: anchor,
|
at: anchor,
|
||||||
|
|
@ -315,7 +293,7 @@
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Defer rendering until navigation finishes
|
// Defer rendering until navigation finishes
|
||||||
let frame = requestAnimationFrame(() => {
|
let frame = requestAnimationFrame(() => {
|
||||||
frame = requestAnimationFrame(() => start(at || now()))
|
frame = requestAnimationFrame(restart)
|
||||||
})
|
})
|
||||||
|
|
||||||
const unsubscribeActive = documentActive.subscribe(onActiveChange)
|
const unsubscribeActive = documentActive.subscribe(onActiveChange)
|
||||||
|
|
@ -335,7 +313,7 @@
|
||||||
class={cx("room__content scroll-container transition-opacity", {
|
class={cx("room__content scroll-container transition-opacity", {
|
||||||
"opacity-0": awaitingJump,
|
"opacity-0": awaitingJump,
|
||||||
})}>
|
})}>
|
||||||
{#if h && $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
|
{#if h && locked}
|
||||||
<div class="py-20">
|
<div class="py-20">
|
||||||
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
|
<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>
|
<p class="opacity-75">You aren't currently a member of this room.</p>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {page} from "$app/stores"
|
||||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -34,4 +35,6 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
|
{#key [$page.url.searchParams.get("at"), $page.url.searchParams.get("event")].join()}
|
||||||
<RoomChat {url} />
|
<RoomChat {url} />
|
||||||
|
{/key}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue