flotilla/src/routes/spaces/[relay]/[h]/+page.svelte

549 lines
16 KiB
Svelte
Raw Normal View History

2024-08-14 00:58:26 +00:00
<script lang="ts">
2026-01-22 20:37:30 +00:00
import {readable, derived as _derived} from "svelte/store"
2025-02-03 23:50:19 +00:00
import {onMount, onDestroy} from "svelte"
2024-08-16 17:50:38 +00:00
import {page} from "$app/stores"
2025-01-28 16:13:20 +00:00
import type {Readable} from "svelte/store"
2025-10-13 20:35:02 +00:00
import type {MakeNonOptional} from "@welshman/lib"
2026-01-22 20:37:30 +00:00
import {now, int, append, remove, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
2024-10-01 18:47:21 +00:00
import type {TrustedEvent, EventContent} from "@welshman/util"
2026-01-22 20:37:30 +00:00
import {throttled} from "@welshman/store"
import {
makeEvent,
makeRoomMeta,
MESSAGE,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
} from "@welshman/util"
2025-10-30 21:59:39 +00:00
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
2025-09-10 22:26:39 +00:00
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
2025-09-12 16:11:53 +00:00
import Login2 from "@assets/icons/login-3.svg?dataurl"
2025-09-10 22:26:39 +00:00
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
2026-01-22 20:37:30 +00:00
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl"
2024-08-16 22:17:30 +00:00
import Icon from "@lib/components/Icon.svelte"
2026-01-22 20:37:30 +00:00
import Link from "@lib/components/Link.svelte"
2024-09-11 19:19:45 +00:00
import Button from "@lib/components/Button.svelte"
2024-08-16 17:50:38 +00:00
import Spinner from "@lib/components/Spinner.svelte"
2024-10-09 23:11:23 +00:00
import PageBar from "@lib/components/PageBar.svelte"
2025-03-19 17:30:36 +00:00
import PageContent from "@lib/components/PageContent.svelte"
2024-09-20 18:00:41 +00:00
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
2025-11-13 18:36:28 +00:00
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
2025-10-30 22:36:14 +00:00
import RoomName from "@app/components/RoomName.svelte"
2025-11-06 00:59:17 +00:00
import RoomImage from "@app/components/RoomImage.svelte"
import RoomDetail from "@app/components/RoomDetail.svelte"
2025-10-30 22:36:14 +00:00
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
2024-10-08 18:39:16 +00:00
import {
deriveUserRooms,
2026-01-28 22:08:15 +00:00
notificationSettings,
userSettingsValues,
2024-10-21 17:30:12 +00:00
decodeRelay,
deriveUserRoomMembershipStatus,
2025-10-30 22:36:14 +00:00
deriveRoom,
2025-05-28 23:46:41 +00:00
MembershipStatus,
2025-10-06 18:23:19 +00:00
PROTECTED,
MESSAGE_KINDS,
} from "@app/core/state"
import {setChecked, checked} from "@app/util/notifications"
import {
canEnforceNip70,
prependParent,
publishDelete,
2026-01-22 20:37:30 +00:00
publishSettings,
addRoomMembership,
removeRoomMembership,
} from "@app/core/commands"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {pushToast} from "@app/util/toast"
2025-10-24 20:36:59 +00:00
import {pushModal} from "@app/util/modal"
2024-08-15 19:08:07 +00:00
2025-10-30 22:33:34 +00:00
const {h, relay} = $page.params as MakeNonOptional<typeof $page.params>
2025-05-09 19:26:05 +00:00
const mounted = now()
const lastChecked = $checked[$page.url.pathname]
2025-10-13 20:35:02 +00:00
const url = decodeRelay(relay)
2025-10-30 22:36:14 +00:00
const room = deriveRoom(url, h)
const shouldProtect = canEnforceNip70(url)
const userRooms = deriveUserRooms(url)
2025-10-30 22:33:34 +00:00
const isFavorite = $derived($userRooms.includes(h))
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
2026-01-22 20:37:30 +00:00
const isMuted = $derived($userSettingsValues.muted_rooms.includes($room.id))
const hasAlerts = throttled(
800,
_derived(
2026-01-28 22:08:15 +00:00
notificationSettings,
({spaces, push, sound, badge}) => spaces && (push || sound || badge),
),
)
2025-01-02 23:08:16 +00:00
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
2025-10-30 22:33:34 +00:00
const addFavorite = () => addRoomMembership(url, h)
2025-02-13 23:34:14 +00:00
2025-10-30 22:33:34 +00:00
const removeFavorite = () => removeRoomMembership(url, h)
2024-12-10 17:44:04 +00:00
2026-01-22 20:37:30 +00:00
const muteRoom = () =>
publishSettings({muted_rooms: append($room.id, $userSettingsValues.muted_rooms)})
const unmuteRoom = () =>
publishSettings({muted_rooms: remove($room.id, $userSettingsValues.muted_rooms)})
2025-05-28 23:46:41 +00:00
const join = async () => {
joining = true
2025-02-13 23:34:14 +00:00
2025-05-28 23:46:41 +00:00
try {
2025-10-30 22:33:34 +00:00
const message = await waitForThunkError(joinRoom(url, makeRoomMeta({h})))
2024-12-10 17:44:04 +00:00
2025-05-28 23:46:41 +00:00
if (message && !message.startsWith("duplicate:")) {
return pushToast({theme: "error", message})
}
// Restart the feed now that we're a member
start()
2025-05-28 23:46:41 +00:00
} finally {
joining = false
}
2024-12-10 17:44:04 +00:00
}
2025-05-28 23:46:41 +00:00
const leave = async () => {
leaving = true
try {
2025-10-30 22:33:34 +00:00
const message = await waitForThunkError(leaveRoom(url, makeRoomMeta({h})))
2025-05-28 23:46:41 +00:00
if (message && !message.startsWith("duplicate:")) {
pushToast({theme: "error", message})
}
} finally {
leaving = false
}
2024-12-10 17:44:04 +00:00
}
const replyTo = (event: TrustedEvent) => {
2025-01-28 16:13:20 +00:00
parent = event
2025-02-04 00:37:14 +00:00
compose?.focus()
2025-01-28 16:13:20 +00:00
}
2025-01-28 16:13:20 +00:00
const clearParent = () => {
parent = undefined
}
2025-02-06 19:33:01 +00:00
const clearShare = () => {
share = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async ({content, tags}: EventContent) => {
2025-10-30 22:33:34 +00:00
tags.push(["h", h])
if (await shouldProtect) {
tags.push(PROTECTED)
}
2025-01-28 16:13:20 +00:00
let template: EventContent & {created_at?: number} = {content, tags}
if (eventToEdit) {
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
}
2025-02-06 19:33:01 +00:00
if (share) {
template = prependParent(share, template)
}
if (parent) {
template = prependParent(parent, template)
}
const thunk = publishThunk({
2024-11-12 22:32:36 +00:00
relays: [url],
2025-06-10 20:35:57 +00:00
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
2024-11-12 22:32:36 +00:00
})
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
clearParent()
2025-02-06 19:33:01 +00:00
clearShare()
clearEventToEdit()
2025-01-28 16:13:20 +00:00
}
2024-10-01 18:47:21 +00:00
const onScroll = () => {
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
2025-12-17 20:07:30 +00:00
const newMessages = document.getElementById("new-messages")
if (!newMessages || newMessagesSeen) {
showFixedNewMessages = false
} else {
const {y} = newMessages.getBoundingClientRect()
if (y > 300) {
newMessagesSeen = true
} else {
showFixedNewMessages = y < 0
}
}
}
const scrollToNewMessages = () =>
2025-12-17 20:07:30 +00:00
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
2025-02-04 00:37:14 +00:00
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
2025-01-28 16:13:20 +00:00
2025-05-28 23:46:41 +00:00
let joining = $state(false)
let leaving = $state(false)
2025-02-13 23:34:14 +00:00
let loadingEvents = $state(true)
2025-02-06 19:33:01 +00:00
let share = $state(popKey<TrustedEvent | undefined>("share"))
2025-02-04 00:37:14 +00:00
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
2025-03-04 20:53:38 +00:00
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
let newMessagesSeen = false
2025-02-04 00:37:14 +00:00
let showFixedNewMessages = $state(false)
let showScrollButton = $state(false)
2025-01-28 16:13:20 +00:00
let cleanup: () => void
2025-02-04 00:37:14 +00:00
let events: Readable<TrustedEvent[]> = $state(readable([]))
2025-10-30 22:36:14 +00:00
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
2024-08-15 21:30:55 +00:00
2025-02-04 00:37:14 +00:00
const elements = $derived.by(() => {
const elements = []
2025-01-28 16:13:20 +00:00
const seen = new Set()
2024-08-15 21:30:55 +00:00
2024-12-09 22:03:59 +00:00
let previousDate
2025-11-14 00:39:44 +00:00
let previousKind
2024-12-09 22:03:59 +00:00
let previousPubkey
2025-12-04 22:27:43 +00:00
let previousCreatedAt = 0
let newMessagesSeen = false
2024-08-15 21:30:55 +00:00
2025-01-28 16:13:20 +00:00
if (events) {
const lastUserEvent = $events.find(e => e.pubkey === $pubkey)
// Adjust last checked to account for messages that came from a different device
const adjustedLastChecked =
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
2025-01-28 16:13:20 +00:00
for (const event of $events.toReversed()) {
2025-02-03 23:40:00 +00:00
if (seen.has(event.id)) {
2025-01-28 16:13:20 +00:00
continue
}
2024-08-15 21:30:55 +00:00
2025-02-03 23:40:00 +00:00
const date = formatTimestampAsDate(event.created_at)
2024-08-15 21:30:55 +00:00
2025-01-28 22:44:43 +00:00
if (
!newMessagesSeen &&
adjustedLastChecked &&
2025-01-28 22:44:43 +00:00
event.pubkey !== $pubkey &&
2025-05-09 19:26:05 +00:00
event.created_at > adjustedLastChecked &&
event.created_at < mounted
2025-01-28 22:44:43 +00:00
) {
elements.push({type: "new-messages", id: "new-messages"})
newMessagesSeen = true
}
2025-01-28 16:13:20 +00:00
if (date !== previousDate) {
elements.push({type: "date", value: date, id: date, showPubkey: false})
}
2024-08-15 21:30:55 +00:00
2025-01-28 16:13:20 +00:00
elements.push({
2025-02-03 23:40:00 +00:00
id: event.id,
2025-01-28 16:13:20 +00:00
type: "note",
value: event,
2025-11-14 00:39:44 +00:00
showPubkey:
previousPubkey !== event.pubkey ||
2025-12-04 22:27:43 +00:00
event.created_at - previousCreatedAt > int(3, MINUTE) ||
2025-11-14 00:39:44 +00:00
[ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER].includes(previousKind!),
2025-01-02 23:08:16 +00:00
})
2025-01-28 16:13:20 +00:00
previousDate = date
2025-11-14 00:39:44 +00:00
previousKind = event.kind
2025-02-03 23:40:00 +00:00
previousPubkey = event.pubkey
2025-12-04 22:27:43 +00:00
previousCreatedAt = event.created_at
2025-02-03 23:40:00 +00:00
seen.add(event.id)
2025-01-02 23:08:16 +00:00
}
2025-01-28 16:13:20 +00:00
}
2025-01-02 23:08:16 +00:00
2025-01-28 16:13:20 +00:00
elements.reverse()
setTimeout(onScroll, 100)
2025-02-04 00:37:14 +00:00
return elements
})
2024-11-15 22:03:54 +00:00
2025-05-28 23:46:41 +00:00
const start = () => {
cleanup?.()
const feed = makeFeed({
2025-10-06 18:23:19 +00:00
url,
2025-02-04 00:37:14 +00:00
element: element!,
2025-10-30 22:33:34 +00:00
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
2025-02-03 23:50:19 +00:00
onExhausted: () => {
2025-02-13 23:34:14 +00:00
loadingEvents = false
2025-02-03 23:50:19 +00:00
},
2025-05-28 23:46:41 +00:00
})
events = feed.events
cleanup = feed.cleanup
}
2025-11-05 00:59:17 +00:00
const onEscape = () => {
clearParent()
clearShare()
eventToEdit = undefined
}
const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $pubkey && event.created_at >= ago(5, MINUTE)
const onEditEvent = (event: TrustedEvent) => {
clearParent()
clearShare()
eventToEdit = event
}
const onEditPrevious = () => {
const prev = $events.find(e => e.pubkey === $pubkey)
if (prev && canEditEvent(prev)) {
onEditEvent(prev)
}
}
2025-05-28 23:46:41 +00:00
onMount(() => {
const observer = new ResizeObserver(() => {
2025-05-08 18:05:27 +00:00
if (dynamicPadding && chatCompose) {
dynamicPadding!.style.minHeight = `${chatCompose!.offsetHeight}px`
}
})
2025-03-04 20:53:38 +00:00
observer.observe(chatCompose!)
2025-05-08 18:05:27 +00:00
observer.observe(dynamicPadding!)
2025-05-28 23:46:41 +00:00
start()
return () => {
2025-03-04 20:53:38 +00:00
observer.unobserve(chatCompose!)
2025-05-08 18:05:27 +00:00
observer.unobserve(dynamicPadding!)
}
2025-02-03 23:50:19 +00:00
})
2025-01-28 16:13:20 +00:00
2025-01-31 17:36:46 +00:00
onDestroy(() => {
cleanup?.()
2025-02-06 19:39:51 +00:00
// Sveltekit calls onDestroy at the beginning of the page load for some reason
setTimeout(() => {
setChecked($page.url.pathname)
2025-02-24 21:40:26 +00:00
}, 800)
2024-11-14 16:33:11 +00:00
})
2024-08-14 00:58:26 +00:00
</script>
2025-03-19 17:30:36 +00:00
<PageBar>
{#snippet icon()}
2025-11-06 00:59:17 +00:00
<RoomImage {url} {h} />
{/snippet}
{#snippet title()}
2025-11-06 00:59:17 +00:00
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
<div class="row-2">
2026-01-22 20:37:30 +00:00
{#if !hasAlerts}
<Link
href="/settings/alerts"
class="center btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Notifications are disabled">
<Icon size={4} icon={VolumeCross} />
</Link>
{:else if isMuted}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Notifications are turned off"
onclick={unmuteRoom}>
<Icon size={4} icon={VolumeCross} />
2026-01-22 20:37:30 +00:00
</Button>
{:else}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Notifications are turned on"
onclick={muteRoom}>
<Icon size={4} icon={VolumeLoud} />
2026-01-22 20:37:30 +00:00
</Button>
{/if}
{#if isFavorite}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left text-secondary"
2026-01-22 20:37:30 +00:00
data-tip="Remove Favorite"
onclick={removeFavorite}>
<Icon size={4} icon={Bookmark} />
2026-01-22 20:37:30 +00:00
</Button>
{:else}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Add Favorite"
onclick={addFavorite}>
<Icon size={4} icon={Bookmark} />
</Button>
{/if}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Room information"
onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
</Button>
2025-11-13 18:36:28 +00:00
<SpaceMenuButton {url} />
</div>
{/snippet}
</PageBar>
2025-03-19 17:30:36 +00:00
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
2025-10-31 23:18:58 +00:00
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
2025-05-28 23:46:41 +00:00
<div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center">
2025-10-31 19:10:16 +00:00
<p class="opacity-75">You aren't currently a member of this room.</p>
2025-10-31 23:18:58 +00:00
{#if !$room.isClosed}
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Join Room
</Button>
{/if}
2025-05-28 23:46:41 +00:00
{/if}
</div>
2025-05-28 23:46:41 +00:00
</div>
{:else}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
2025-12-17 20:07:30 +00:00
{id}
2025-05-28 23:46:41 +00:00
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px flex-grow bg-primary"></div>
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
<div class="h-px flex-grow bg-primary"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = $state.snapshot(value as TrustedEvent)}
{#if event.kind === ROOM_ADD_MEMBER}
2025-10-30 22:36:14 +00:00
<RoomItemAddMember {url} {event} />
{:else if event.kind === ROOM_REMOVE_MEMBER}
2025-10-30 22:36:14 +00:00
<RoomItemRemoveMember {url} {event} />
{:else}
<div in:slide class:-mt-1={!showPubkey}>
2025-10-30 22:36:14 +00:00
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
2025-05-28 23:46:41 +00:00
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
{/if}
2025-03-19 17:30:36 +00:00
</PageContent>
2025-03-04 20:53:38 +00:00
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
2025-10-31 23:18:58 +00:00
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
2025-05-28 23:46:41 +00:00
<!-- pass -->
2025-10-31 23:18:58 +00:00
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
2025-05-29 17:42:43 +00:00
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
2025-11-03 19:10:16 +00:00
<p class="opacity-75">Only members are allowed to post to this room.</p>
2025-10-31 23:18:58 +00:00
{#if !$room.isClosed}
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
2025-05-28 23:46:41 +00:00
{/if}
</div>
{:else}
<div>
{#if parent}
2025-10-30 22:36:14 +00:00
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
2025-05-28 23:46:41 +00:00
{/if}
{#if share}
2025-10-30 22:36:14 +00:00
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
2025-05-28 23:46:41 +00:00
{/if}
{#if eventToEdit}
2025-10-30 22:36:14 +00:00
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
2025-05-28 23:46:41 +00:00
</div>
{#key eventToEdit}
2025-10-30 22:36:14 +00:00
<RoomCompose
{url}
2025-10-30 22:33:34 +00:00
{h}
2025-10-06 18:23:19 +00:00
{onSubmit}
2025-11-05 00:59:17 +00:00
{onEscape}
2025-10-06 18:23:19 +00:00
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
{/key}
2025-05-28 23:46:41 +00:00
{/if}
2024-08-15 19:08:07 +00:00
</div>
2025-01-28 16:13:20 +00:00
{#if showScrollButton}
<div in:fade class="chat__scroll-down">
<Button class="btn btn-circle btn-neutral" onclick={scrollToBottom}>
2025-09-10 22:26:39 +00:00
<Icon icon={AltArrowDown} />
2025-01-28 16:13:20 +00:00
</Button>
</div>
{/if}
{#if showFixedNewMessages}
2025-11-26 01:06:21 +00:00
<div class="relative z-popover flex justify-center">
<div transition:fly={{duration: 200}} class="fixed top-12">
<Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}>
New Messages
</Button>
</div>
</div>
{/if}