Put a create thread button on every board and drop the room picker
This commit is contained in:
parent
76bbacdbd6
commit
98290e1a9b
5 changed files with 40 additions and 58 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue