Add non-nip29 chat, add leave room

This commit is contained in:
Jon Staab
2025-05-29 10:42:43 -07:00
parent f7d11cf124
commit ac8530bd9a
7 changed files with 353 additions and 49 deletions
+31 -17
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import cx from 'classnames'
import cx from "classnames"
import {readable} from "svelte/store"
import {onMount, onDestroy} from "svelte"
import {page} from "$app/stores"
@@ -7,8 +7,15 @@
import {now, formatTimestampAsDate} from "@welshman/lib"
import {request} from "@welshman/net"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, MESSAGE, DELETE, REACTION, GROUP_ADD_USER, GROUP_REMOVE_USER} from "@welshman/util"
import {pubkey, publishThunk, deriveRelay, getThunkError, waitForThunkCompletion} from "@welshman/app"
import {
createEvent,
MESSAGE,
DELETE,
REACTION,
GROUP_ADD_USER,
GROUP_REMOVE_USER,
} from "@welshman/util"
import {pubkey, publishThunk, getThunkError} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -26,7 +33,6 @@
userSettingValues,
decodeRelay,
tagRoom,
displayChannel,
getEventsForUrl,
deriveUserMembershipStatus,
deriveChannel,
@@ -240,15 +246,17 @@
onMount(() => {
const controller = new AbortController()
const req = request({
request({
signal: controller.signal,
relays: [url],
filters: [{
kinds: [GROUP_ADD_USER, GROUP_REMOVE_USER],
'#p': [$pubkey!],
'#h': [room],
limit: 10,
}],
filters: [
{
kinds: [GROUP_ADD_USER, GROUP_REMOVE_USER],
"#p": [$pubkey!],
"#h": [room],
limit: 10,
},
],
})
const observer = new ResizeObserver(() => {
@@ -291,11 +299,20 @@
{/snippet}
{#snippet action()}
<div class="row-2">
{#if $membershipStatus !== MembershipStatus.Initial}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Request to be removed from member list"
disabled={leaving}
onclick={leave}>
<Icon size={4} icon="arrows-a-logout-2" />
</Button>
{/if}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip={isFavorite ? "Remove Favorite" : "Add Favorite"}
onclick={isFavorite ? removeFavorite : addFavorite}>
<Icon size={4} icon="bookmark" class={cx({'text-primary': isFavorite})} />
<Icon size={4} icon="bookmark" class={cx({"text-primary": isFavorite})} />
</Button>
<MenuSpaceButton {url} />
</div>
@@ -307,9 +324,7 @@
{#if $channel?.private && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center">
<p class="row-2">
You aren't currently a member of this room.
</p>
<p class="row-2">You aren't currently a member of this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon="clock-circle" />
@@ -344,7 +359,6 @@
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage
{url}
{room}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
{showPubkey} />
@@ -365,7 +379,7 @@
{#if $channel?.private && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $channel?.closed && $membershipStatus !== MembershipStatus.Granted}
<div class="flex flex-row items-center justify-between m-4 px-4 py-3 card bg-alt">
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
<p>Only members are allowed to post to this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
+279
View File
@@ -0,0 +1,279 @@
<script lang="ts">
import {readable} from "svelte/store"
import {onMount, onDestroy} from "svelte"
import {page} from "$app/stores"
import type {Readable} from "svelte/store"
import {now, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, MESSAGE, DELETE, REACTION} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {userSettingValues, decodeRelay, getEventsForUrl} from "@app/state"
import {setChecked, checked} from "@app/notifications"
import {prependParent} from "@app/commands"
import {PROTECTED} from "@app/state"
import {makeFeed} from "@app/requests"
import {popKey} from "@app/implicit"
const mounted = now()
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay($page.params.relay)
const filter = {kinds: [MESSAGE]}
const replyTo = (event: TrustedEvent) => {
parent = event
compose?.focus()
}
const clearParent = () => {
parent = undefined
}
const clearShare = () => {
share = undefined
}
const onSubmit = ({content, tags}: EventContent) => {
tags.push(PROTECTED)
let template = {content, tags}
if (share) {
template = prependParent(share, template)
}
if (parent) {
template = prependParent(parent, template)
}
publishThunk({
relays: [url],
event: createEvent(MESSAGE, template),
delay: $userSettingValues.send_delay,
})
clearParent()
clearShare()
}
const onScroll = () => {
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
if (!newMessages || newMessagesSeen) {
showFixedNewMessages = false
} else {
const {y} = newMessages.getBoundingClientRect()
if (y > 300) {
newMessagesSeen = true
} else {
showFixedNewMessages = y < 0
}
}
}
const scrollToNewMessages = () =>
newMessages?.scrollIntoView({behavior: "smooth", block: "center"})
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
let loadingEvents = $state(true)
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
let newMessages: HTMLElement | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
let newMessagesSeen = false
let showFixedNewMessages = $state(false)
let showScrollButton = $state(false)
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: ChannelCompose | undefined = $state()
const elements = $derived.by(() => {
const elements = []
const seen = new Set()
let previousDate
let previousPubkey
let newMessagesSeen = false
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
for (const event of $events.toReversed()) {
if (seen.has(event.id)) {
continue
}
const date = formatTimestampAsDate(event.created_at)
if (
!newMessagesSeen &&
adjustedLastChecked &&
event.pubkey !== $pubkey &&
event.created_at > adjustedLastChecked &&
event.created_at < mounted
) {
elements.push({type: "new-messages", id: "new-messages"})
newMessagesSeen = true
}
if (date !== previousDate) {
elements.push({type: "date", value: date, id: date, showPubkey: false})
}
elements.push({
id: event.id,
type: "note",
value: event,
showPubkey: date !== previousDate || previousPubkey !== event.pubkey,
})
previousDate = date
previousPubkey = event.pubkey
seen.add(event.id)
}
}
elements.reverse()
setTimeout(onScroll, 100)
return elements
})
onMount(() => {
const controller = new AbortController()
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding!.style.minHeight = `${chatCompose!.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
const feed = makeFeed({
element: element!,
relays: [url],
feedFilters: [filter],
subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], since: now()}],
initialEvents: getEventsForUrl(url, [{...filter, limit: 20}]),
onExhausted: () => {
loadingEvents = false
},
})
events = feed.events
cleanup = feed.cleanup
return () => {
controller.abort()
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
})
onDestroy(() => {
cleanup()
// Sveltekit calls onDestroy at the beginning of the page load for some reason
setTimeout(() => {
setChecked($page.url.pathname)
}, 800)
})
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon="chat-round" />
</div>
{/snippet}
{#snippet title()}
<strong>Chat</strong>
{/snippet}
{#snippet action()}
<MenuSpaceButton {url} />
{/snippet}
</PageBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
bind:this={newMessages}
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}
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage
{url}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
{showPubkey} />
</div>
{/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>
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
</div>
<ChannelCompose bind:this={compose} {onSubmit} {url} />
</div>
{#if showScrollButton}
<div in:fade class="chat__scroll-down">
<Button class="btn btn-circle btn-neutral" onclick={scrollToBottom}>
<Icon icon="alt-arrow-down" />
</Button>
</div>
{/if}
{#if showFixedNewMessages}
<div class="relative z-feature 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}