Add a create post button to the home page and to a profile (#660)
This commit is contained in:
parent
3c85491250
commit
c70a31a682
5 changed files with 220 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
127
src/app/components/NoteCreate.svelte
Normal file
127
src/app/components/NoteCreate.svelte
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue