Add a create post button to the home page and to a profile (#660)

This commit is contained in:
Coracle-Bot 2026-09-28 22:08:14 +00:00 committed by hodlbod
parent 3c85491250
commit c70a31a682
5 changed files with 220 additions and 3 deletions

View file

@ -1281,6 +1281,18 @@ Acceptance:
- A note alice publishes from her own session appears at the top of bob's open
view without a reload.
### US-134 — Write a public post
As alice, I want to write a post from my own profile or from my network feed, so
that the people who follow me and the spaces I favorited both see it.
Acceptance:
- "Create post" on her profile and at the top of home's Network section both
open the same composer.
- Submitting it publishes the post to her own relays and to every space she
favorited, and it appears at the top of her profile's notes.
### US-080 — Preview a profile from anywhere
As alice, I want to click a name or avatar wherever it appears, so that I can

View file

@ -1,7 +1,7 @@
import {createHash} from "node:crypto"
import type {Locator, Page} from "@playwright/test"
import {npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE, MONTH} from "@welshman/lib"
import {HOUR, MINUTE, MONTH, uniq} from "@welshman/lib"
import {NOTE, STATUS, makeEvent} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import {FollowList, Note, PinList, Profile} from "@welshman/domain"
@ -10,7 +10,9 @@ import {
GIF,
WEBP,
expect,
getPublished,
mockBlossom,
noteEditor,
profilePath,
spacePath,
test,
@ -397,6 +399,59 @@ test("US-079 read a person's notes", async ({seed, as}) => {
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0)
})
test("US-134 write a public post", async ({seed, as}) => {
const post = "the wind has finally turned"
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.relayList(user.alice)
// Her second space is in no relay list of hers, so a post reaching it was routed there as a space.
other.room("general", {name: "General"})
other.join(user.alice, "general")
})
const page = await as(users.alice, profilePath(users.alice.pubkey), {context: DESKTOP})
await page.getByRole("button", {name: "Create post"}).click()
const composer = topDialog(page)
await expect(composer.getByText("Create a Public Post")).toBeVisible()
await noteEditor(composer).click()
await noteEditor(composer).pressSequentially(post)
await composer.getByRole("button", {name: "Post"}).click()
await expect(notes(page).filter({hasText: post})).toBeVisible()
await expect
.poll(() =>
uniq(
getPublished(page.context())
.filter(({event}) => event.content === post)
.map(({url}) => url),
).sort(),
)
.toEqual([scenario.space("other").url, scenario.space("space").url].sort())
// The same composer is how a post starts from home, out of the feed it posts into.
await page.goto("/home")
const network = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "Network"})})
await network.getByRole("button", {name: "Create post"}).click()
await expect(topDialog(page).getByText("Create a Public Post")).toBeVisible()
})
test("US-080 preview a profile from anywhere", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")

View file

@ -8,13 +8,18 @@
import type {TrustedEvent} from "@welshman/util"
import {getReplyTags} from "@welshman/domain"
import Planet from "@assets/icons/planet.svg?dataurl"
import PenNewSquare from "@assets/icons/pen-new-square.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import HomeSection from "@app/components/HomeSection.svelte"
import HomeNetworkItem from "@app/components/HomeNetworkItem.svelte"
import NoteCreate from "@app/components/NoteCreate.svelte"
import {CONTENT_KINDS} from "@app/content"
import {followLists, relayLists, router, user} from "@app/core"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {pushModal} from "@app/modal"
type Props = {
class?: string
@ -39,6 +44,8 @@
let started = false
let stop: Maybe<() => void>
const createPost = () => pushModal(NoteCreate)
// Kind 1 replies read as half a conversation without their parent.
const isRoot = (event: TrustedEvent) =>
event.kind !== NOTE || getReplyTags(event.tags).replies.length === 0
@ -85,6 +92,12 @@
</script>
<HomeSection title="Network" icon={Planet} class={className}>
{#snippet action()}
<Button class="button button-primary button-xs" onclick={createPost}>
<Icon icon={PenNewSquare} size={4} />
Create post
</Button>
{/snippet}
<div class="flex flex-col gap-3 border-t border-line bg-surface-less p-4" bind:this={element}>
{#if isEmpty}
<div class="flex flex-col items-center gap-3 pb-4 text-center">

View file

@ -0,0 +1,127 @@
<script lang="ts">
import {writable} from "svelte/store"
import {uniq} from "@welshman/lib"
import {Note} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html"
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"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {publisher, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {userSpaceUrls} from "@app/rooms"
import {pushToast} from "@app/toast"
type Values = {
content?: string | object
}
const draftKey = new DraftKey<Values>("note")
const draft = draftKey.get()
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) {
return
}
const ed = await editor
const text = ed.getText({blockSeparator: "\n"}).trim()
if (!text) {
return pushToast({theme: "error", message: "Please write something first."})
}
loading = true
try {
const {event, relays} = await writer(Note)
.setContent(text)
.addTags(...ed.storage.nostr.getEditorTags())
.render()
const publication = $publisher.publish({
event,
relays: uniq([...relays, ...$userSpaceUrls]),
})
const error = (await publication.settled()).getError()
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
} finally {
loading = false
}
}
let loading = $state(false)
let content = $state(draft?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
submit,
uploading,
onChange,
content,
placeholder: "What's on your mind?",
})
$effect(() => {
draftKey.update({content})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Public Post</ModalTitle>
<ModalSubtitle>Published to your outbox and all favorited spaces.</ModalSubtitle>
</ModalHeader>
<div class="relative">
<div class="note-editor grow overflow-hidden">
<EditorContent autofocus={!isMobile} {editor} />
</div>
<Button
data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="button button-primary" disabled={$uploading || loading}>
<Spinner {loading}>Post</Spinner>
</Button>
</ModalFooter>
</Modal>

View file

@ -6,6 +6,7 @@
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl"
import UserPlus from "@assets/icons/user-plus.svg?dataurl"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import PenNewSquare from "@assets/icons/pen-new-square.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -21,6 +22,7 @@
import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte"
import ProfileMenu from "@app/components/ProfileMenu.svelte"
import NoteCreate from "@app/components/NoteCreate.svelte"
import WotScore from "@app/components/WotScore.svelte"
import {compressFileForUpload, uploadFile} from "@app/uploads"
import {pushModal} from "@app/modal"
@ -53,6 +55,8 @@
const startEdit = () => pushModal(ProfileEdit)
const createPost = () => pushModal(NoteCreate)
const openChat = () => goToChat([target])
const toggleFollow = async () => {
@ -154,11 +158,17 @@
<div class="flex items-center gap-2">
{#if isSelf}
<Button
class="button button-primary flex-1 sm:button-sm sm:flex-none"
class="button button-neutral flex-1 sm:button-sm sm:flex-none"
onclick={startEdit}>
<Icon icon={PenNewSquare} size={4} />
<Icon icon={Pen2} size={4} />
Edit profile
</Button>
<Button
class="button button-primary flex-1 sm:button-sm sm:flex-none"
onclick={createPost}>
<Icon icon={PenNewSquare} size={4} />
Create post
</Button>
{:else}
<Button
class="button button-neutral flex-1 sm:button-sm sm:flex-none"