Put a create thread button on every board and drop the room picker

This commit is contained in:
Coracle-Bot 2026-09-09 17:13:16 +00:00 committed by hodlbod
parent 76bbacdbd6
commit 98290e1a9b
5 changed files with 40 additions and 58 deletions

View file

@ -682,8 +682,10 @@ Acceptance:
- Creating a thread from a room's compose menu files it under that room's board - Creating a thread from a room's compose menu files it under that room's board
and posts a quote of it into the room's chat. and posts a quote of it into the room's chat.
- Creating a thread from the top-level Threads page files it under a "General" - Each board on the Threads page has its own create button, and a thread started
board, or under whichever board is picked from its board selector. from it is filed under that board.
- The "General" board is always present, so a thread belonging to no room can be
started from the Threads page.
- Each board row shows the topic title, author, reply count, and last-post time. - Each board row shows the topic title, author, reply count, and last-post time.
### US-043 — Reply to a thread and to a specific post ### US-043 — Reply to a thread and to a specific post

View file

@ -676,7 +676,13 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as})
await shortDate(page, at(4, HOUR) + 60), await shortDate(page, at(4, HOUR) + 60),
) )
await pageBar(page).getByRole("button", {name: "Create", exact: true}).click() const general = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "General", exact: true})})
// Each board creates its own threads, so the room comes from the button that was clicked rather
// than from a picker.
await general.getByRole("button", {name: "Create", exact: true}).click()
const fromThreads = modal(page, "Create a Thread") const fromThreads = modal(page, "Create a Thread")
@ -686,18 +692,12 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as})
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0) await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
const general = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "General", exact: true})})
await expect(general.getByRole("row").filter({hasText: "Open floor"})).toBeVisible() await expect(general.getByRole("row").filter({hasText: "Open floor"})).toBeVisible()
// The threads page has no room of its own, so the form offers one. await lounge.getByRole("button", {name: "Create", exact: true}).click()
await pageBar(page).getByRole("button", {name: "Create", exact: true}).click()
const toLounge = modal(page, "Create a Thread") const toLounge = modal(page, "Create a Thread")
await toLounge.getByRole("combobox").selectOption({label: "Lounge"})
await toLounge.getByPlaceholder("What is this thread about?").fill("Carpet swatches") await toLounge.getByPlaceholder("What is this thread about?").fill("Carpet swatches")
await editorOf(toLounge).pressSequentially("Beige is a choice.") await editorOf(toLounge).pressSequentially("Beige is a choice.")
await toLounge.getByRole("button", {name: "Create Thread"}).click() await toLounge.getByRole("button", {name: "Create Thread"}).click()

View file

@ -1,9 +1,14 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Add from "@assets/icons/add.svg?dataurl"
import {matchMd} from "@lib/theme" import {matchMd} from "@lib/theme"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte" import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {pushModal} from "@app/modal"
type Props = { type Props = {
url: string url: string
@ -13,6 +18,8 @@
} }
const {url, h, threads, context}: Props = $props() const {url, h, threads, context}: Props = $props()
const createThread = () => pushModal(ThreadCreate, {url, h})
</script> </script>
<section class="card card-flat p-0"> <section class="card card-flat p-0">
@ -25,12 +32,20 @@
General General
{/if} {/if}
</h2> </h2>
<span class="text-content-muted text-sm"> <div class="flex shrink-0 items-center gap-3">
{threads.length} <span class="text-content-muted text-sm">
{threads.length === 1 ? "Topic" : "Topics"} {threads.length}
</span> {threads.length === 1 ? "Topic" : "Topics"}
</span>
<Button class="button button-primary button-sm" onclick={createThread}>
<Icon icon={Add} />
Create
</Button>
</div>
</header> </header>
{#if $matchMd} {#if threads.length === 0}
<p class="text-content-muted p-4 text-sm">No topics yet.</p>
{:else if $matchMd}
<div class="scroll-container overflow-x-auto pb-4"> <div class="scroll-container overflow-x-auto pb-4">
<table class="w-full min-w-[640px] border-collapse"> <table class="w-full min-w-[640px] border-collapse">
<thead <thead

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {sortBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {relay} from "@welshman/util" import {relay} from "@welshman/util"
import {Thread} from "@welshman/domain" import {Thread} from "@welshman/domain"
@ -22,13 +21,7 @@
import {command, relays, writer} from "@app/core" import {command, relays, writer} from "@app/core"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import { import {displayRoom, publishRoomQuote} from "@app/rooms"
deriveOtherRooms,
deriveUserRooms,
displayRoom,
publishRoomQuote,
roomComparator,
} from "@app/rooms"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Values = { type Values = {
@ -40,19 +33,16 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
selectRoom?: boolean
shareToChat?: boolean shareToChat?: boolean
quote?: TrustedEvent quote?: TrustedEvent
initialValues?: Values initialValues?: Values
} }
const {url, h, selectRoom = false, shareToChat = false, quote, initialValues}: Props = $props() const {url, h, shareToChat = false, quote, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`) const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
const draft = draftKey.get() const draft = draftKey.get()
const shouldProtect = $relays.hasNip(url, 70) const shouldProtect = $relays.hasNip(url, 70)
const userRooms = deriveUserRooms(url) const room = h ?? initialValues?.h ?? draft?.h ?? ""
const otherRooms = deriveOtherRooms(url)
const roomOptions = $derived(sortBy(roomComparator(url), [...$userRooms, ...$otherRooms]))
const uploading = writable(false) const uploading = writable(false)
@ -121,7 +111,6 @@
let title = $state(initialValues?.title ?? draft?.title ?? "") let title = $state(initialValues?.title ?? draft?.title ?? "")
let content = $state(initialValues?.content ?? draft?.content ?? "") let content = $state(initialValues?.content ?? draft?.content ?? "")
let room = $state(h ?? initialValues?.h ?? draft?.h ?? "")
const onChange = (json: object) => { const onChange = (json: object) => {
content = json content = json
@ -145,7 +134,9 @@
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Create a Thread</ModalTitle> <ModalTitle>Create a Thread</ModalTitle>
<ModalSubtitle>Share a link, or start a discussion.</ModalSubtitle> <ModalSubtitle>
Share a link, or start a discussion in {room ? displayRoom(url, room) : "General"}.
</ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-8 relative"> <div class="flex flex-col gap-8 relative">
<Field> <Field>
@ -164,21 +155,6 @@
</label> </label>
{/snippet} {/snippet}
</Field> </Field>
{#if selectRoom && roomOptions.length > 0}
<Field>
{#snippet label()}
<p>Board</p>
{/snippet}
{#snippet input()}
<select class="select input w-full" bind:value={room}>
<option value="">General</option>
{#each roomOptions as option (option)}
<option value={option}>{displayRoom(url, option)}</option>
{/each}
</select>
{/snippet}
</Field>
{/if}
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Message*</p> <p>Message*</p>

View file

@ -8,19 +8,15 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {THREAD, tagValue, tagSpec} from "@welshman/util" import {THREAD, tagValue, tagSpec} from "@welshman/util"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" 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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import ThreadBoard from "@app/components/ThreadBoard.svelte" import ThreadBoard from "@app/components/ThreadBoard.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {displayRoom} from "@app/rooms" import {displayRoom} from "@app/rooms"
import {makeCommentFilter} from "@app/content" import {makeCommentFilter} from "@app/content"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const context = makeFeedContext({relays: [url]}) const context = makeFeedContext({relays: [url]})
@ -34,8 +30,6 @@
let element: HTMLElement | undefined = $state() let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([])) let events: Readable<TrustedEvent[]> = $state(readable([]))
const createThread = () => pushModal(ThreadCreate, {url, selectRoom: true})
const threadFeed = $derived.by(() => { const threadFeed = $derived.by(() => {
const scores = new Map<string, number[]>() const scores = new Map<string, number[]>()
const [threads, comments] = partition(spec({kind: THREAD}), $events) const [threads, comments] = partition(spec({kind: THREAD}), $events)
@ -50,7 +44,8 @@
const items = sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), threads) const items = sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), threads)
const byRoom = groupBy(e => tagValue(tagSpec("h"), e.tags) || "", items) const grouped = groupBy(e => tagValue(tagSpec("h"), e.tags) || "", items)
const byRoom = new Map<string, TrustedEvent[]>([["", []], ...grouped])
const roomName = (h: string) => (h ? displayRoom(url, h) : "general").toLowerCase() const roomName = (h: string) => (h ? displayRoom(url, h) : "general").toLowerCase()
const boards = sortBy(([h]) => roomName(h), Array.from(byRoom.entries())) const boards = sortBy(([h]) => roomName(h), Array.from(byRoom.entries()))
@ -84,12 +79,6 @@
{#snippet title()} {#snippet title()}
<strong>Threads</strong> <strong>Threads</strong>
{/snippet} {/snippet}
{#snippet action()}
<Button class="button button-primary button-sm" onclick={createThread}>
<Icon icon={Add} />
Create
</Button>
{/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4"> <PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">