threads in rooms.

This commit is contained in:
fiatjaf
2026-06-05 13:37:18 -03:00
parent b6b8145901
commit 0849f7ee4c
5 changed files with 198 additions and 9 deletions
+78 -7
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import {writable} from "svelte/store"
import {deriveRelay, publishThunk, waitForThunkError} from "@welshman/app"
import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@@ -16,8 +17,12 @@
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {hasNip29, roomsByUrl, PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
@@ -33,8 +38,11 @@
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
const {url, h: initialH, shareToChat = false}: Props = $props()
const relay = deriveRelay(url)
const rooms = $derived(($roomsByUrl.get(url) || []).map(room => room.h))
const isNip29 = $derived(hasNip29($relay))
const draftKey = new DraftKey<Values>(`thread:${url}:${initialH ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
@@ -44,6 +52,20 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const toggleRoomPicker = () => {
showRoomPicker = !showRoomPicker
}
const pickSpace = () => {
selectedH = undefined
showRoomPicker = false
}
const pickRoom = (h: string) => {
selectedH = h
showRoomPicker = false
}
const submit = async () => {
if ($uploading || loading) return
@@ -75,8 +97,8 @@
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
if (selectedH) {
tags.push(["h", selectedH])
}
const threadThunk = publishThunk({
@@ -94,7 +116,7 @@
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: threadThunk.event, protect})
publishRoomQuote({url, h: selectedH, parent: threadThunk.event, protect})
}
} finally {
loading = false
@@ -102,6 +124,8 @@
}
let loading = $state(false)
let showRoomPicker = $state(false)
let selectedH: string | undefined = $state(initialH)
let title = $state(initialValues?.title ?? "")
let content = $state(initialValues?.content ?? "")
@@ -131,6 +155,53 @@
<ModalSubtitle>Share a link, or start a discussion.</ModalSubtitle>
</ModalHeader>
<div class="col-8 relative">
{#if isNip29}
<Field>
{#snippet label()}
<p>Room</p>
{/snippet}
{#snippet input()}
<div class="relative">
<Button
type="button"
class="select select-bordered flex w-full items-center gap-2"
onclick={toggleRoomPicker}>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if selectedH}
<RoomImage {url} h={selectedH} size={4} />
<RoomName {url} h={selectedH} class="truncate text-left" />
{:else}
<RelayIcon {url} size={4} />
<RelayName {url} class="truncate text-left" />
{/if}
</div>
<Icon icon={AltArrowDown} size={4} />
</Button>
{#if showRoomPicker}
<div
class="absolute z-popover mt-1 w-full rounded-lg border border-base-300 bg-base-100 shadow-xl">
<Button
type="button"
class="flex w-full items-center gap-2 rounded-none border-0 bg-transparent px-3 py-2 hover:bg-base-200"
onclick={pickSpace}>
<RelayIcon {url} size={4} />
<RelayName {url} />
</Button>
{#each rooms as h (h)}
<Button
type="button"
class="flex w-full items-center gap-2 rounded-none border-0 bg-transparent px-3 py-2 hover:bg-base-200"
onclick={() => pickRoom(h)}>
<RoomImage {url} {h} size={4} />
<RoomName {url} {h} />
</Button>
{/each}
</div>
{/if}
</div>
{/snippet}
</Field>
{/if}
<Field>
{#snippet label()}
<p>Title*</p>
+11 -2
View File
@@ -41,6 +41,7 @@ import {
removeFromListByPredicate,
updateList,
getTag,
getTagValue,
getListTags,
getRelayTagValues,
toNostrURI,
@@ -391,8 +392,16 @@ export type CommentParams = {
url?: string
}
export const makeComment = ({url, event, content, tags = []}: CommentParams) =>
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]})
export const makeComment = ({url, event, content, tags = []}: CommentParams) => {
const allTags = [...tags, ...tagEventForComment(event, url)]
const h = getTagValue("h", event.tags)
if (h) {
allTags.push(["h", h])
}
return makeEvent(COMMENT, {content, tags: allTags})
}
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment({url: relays[0], ...params}), relays})
+1
View File
@@ -14,6 +14,7 @@ const staticTitles = new Map<string, string>([
["/spaces/[relay]/chat", "Space Chat"],
["/spaces/[relay]/recent", "Recent Activity"],
["/spaces/[relay]/threads", "Threads"],
["/spaces/[relay]/[h]/threads", "Threads"],
["/spaces/[relay]/classifieds", "Classifieds"],
["/spaces/[relay]/calendar", "Calendar"],
["/spaces/[relay]/goals", "Goals"],
@@ -14,11 +14,13 @@
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import cx from "classnames"
import {fade, fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
@@ -430,6 +432,9 @@
{/snippet}
{#snippet action()}
<SpaceSearch {url} {h} />
<Link href={roomPath + "/threads"} class="btn btn-neutral btn-sm btn-square">
<Icon size={4} icon={NotesMinimalistic} />
</Link>
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
</Button>
@@ -0,0 +1,103 @@
<script lang="ts">
import {onMount} from "svelte"
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {THREAD, getTagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {decodeRelay, makeCommentFilter} from "@app/core/state"
import {makeFeed} from "@app/core/requests"
import {pushModal} from "@app/util/modal"
const {relay, h} = $page.params as {relay: string; h: string}
const url = decodeRelay(relay)
let loading = $state(true)
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([]))
const createThread = () => pushModal(ThreadCreate, {url, h})
const items = $derived.by(() => {
const scores = new Map<string, number[]>()
const [threads, comments] = partition(spec({kind: THREAD}), $events)
for (const comment of comments) {
const id = getTagValue("E", comment.tags)
if (id) {
pushToMapKey(scores, id, comment.created_at)
}
}
return sortBy(event => -max([...(scores.get(event.id) || []), event.created_at]), threads)
})
onMount(() => {
const feed = makeFeed({
url,
element: element!,
filters: [{kinds: [THREAD], "#h": [h]}, makeCommentFilter([THREAD], {"#h": [h]})],
onBackwardExhausted: () => {
loading = false
},
})
events = feed.events
return () => {
feed.cleanup()
}
})
</script>
<SpaceBar>
{#snippet leading()}
<RoomImage {url} {h} />
{/snippet}
{#snippet title()}
<div class="flex min-w-0 items-center gap-2">
<RoomName {url} {h} />
<Icon icon={NotesMinimalistic} size={4} class="opacity-60" />
<strong>Threads</strong>
</div>
{/snippet}
{#snippet action()}
<Button class="btn btn-sm btn-primary" onclick={createThread}>
<Icon icon={Add} />
Create
</Button>
{/snippet}
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2">
{#each items as event (event.id)}
<div in:fly>
<ThreadItem {url} event={$state.snapshot(event)} />
</div>
{/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for threads...
{:else if items.length === 0}
No threads found.
{:else}
That's all!
{/if}
</Spinner>
</p>
</PageContent>