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
|
- A note alice publishes from her own session appears at the top of bob's open
|
||||||
view without a reload.
|
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
|
### US-080 — Preview a profile from anywhere
|
||||||
|
|
||||||
As alice, I want to click a name or avatar wherever it appears, so that I can
|
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 {createHash} from "node:crypto"
|
||||||
import type {Locator, Page} from "@playwright/test"
|
import type {Locator, Page} from "@playwright/test"
|
||||||
import {npubEncode} from "nostr-tools/nip19"
|
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 {NOTE, STATUS, makeEvent} from "@welshman/util"
|
||||||
import type {SignedEvent} from "@welshman/util"
|
import type {SignedEvent} from "@welshman/util"
|
||||||
import {FollowList, Note, PinList, Profile} from "@welshman/domain"
|
import {FollowList, Note, PinList, Profile} from "@welshman/domain"
|
||||||
|
|
@ -10,7 +10,9 @@ import {
|
||||||
GIF,
|
GIF,
|
||||||
WEBP,
|
WEBP,
|
||||||
expect,
|
expect,
|
||||||
|
getPublished,
|
||||||
mockBlossom,
|
mockBlossom,
|
||||||
|
noteEditor,
|
||||||
profilePath,
|
profilePath,
|
||||||
spacePath,
|
spacePath,
|
||||||
test,
|
test,
|
||||||
|
|
@ -397,6 +399,59 @@ test("US-079 read a person's notes", async ({seed, as}) => {
|
||||||
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0)
|
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}) => {
|
test("US-080 preview a profile from anywhere", async ({seed, as}) => {
|
||||||
const scenario = await seed(({relay, user}) => {
|
const scenario = await seed(({relay, user}) => {
|
||||||
const space = relay("space")
|
const space = relay("space")
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,18 @@
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getReplyTags} from "@welshman/domain"
|
import {getReplyTags} from "@welshman/domain"
|
||||||
import Planet from "@assets/icons/planet.svg?dataurl"
|
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 Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import HomeSection from "@app/components/HomeSection.svelte"
|
import HomeSection from "@app/components/HomeSection.svelte"
|
||||||
import HomeNetworkItem from "@app/components/HomeNetworkItem.svelte"
|
import HomeNetworkItem from "@app/components/HomeNetworkItem.svelte"
|
||||||
|
import NoteCreate from "@app/components/NoteCreate.svelte"
|
||||||
import {CONTENT_KINDS} from "@app/content"
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
import {followLists, relayLists, router, user} from "@app/core"
|
import {followLists, relayLists, router, user} from "@app/core"
|
||||||
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
class?: string
|
class?: string
|
||||||
|
|
@ -39,6 +44,8 @@
|
||||||
let started = false
|
let started = false
|
||||||
let stop: Maybe<() => void>
|
let stop: Maybe<() => void>
|
||||||
|
|
||||||
|
const createPost = () => pushModal(NoteCreate)
|
||||||
|
|
||||||
// Kind 1 replies read as half a conversation without their parent.
|
// Kind 1 replies read as half a conversation without their parent.
|
||||||
const isRoot = (event: TrustedEvent) =>
|
const isRoot = (event: TrustedEvent) =>
|
||||||
event.kind !== NOTE || getReplyTags(event.tags).replies.length === 0
|
event.kind !== NOTE || getReplyTags(event.tags).replies.length === 0
|
||||||
|
|
@ -85,6 +92,12 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<HomeSection title="Network" icon={Planet} class={className}>
|
<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}>
|
<div class="flex flex-col gap-3 border-t border-line bg-surface-less p-4" bind:this={element}>
|
||||||
{#if isEmpty}
|
{#if isEmpty}
|
||||||
<div class="flex flex-col items-center gap-3 pb-4 text-center">
|
<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 LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||||
import UserPlus from "@assets/icons/user-plus.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 PenNewSquare from "@assets/icons/pen-new-square.svg?dataurl"
|
||||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte"
|
import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte"
|
||||||
import ProfileEdit from "@app/components/ProfileEdit.svelte"
|
import ProfileEdit from "@app/components/ProfileEdit.svelte"
|
||||||
import ProfileMenu from "@app/components/ProfileMenu.svelte"
|
import ProfileMenu from "@app/components/ProfileMenu.svelte"
|
||||||
|
import NoteCreate from "@app/components/NoteCreate.svelte"
|
||||||
import WotScore from "@app/components/WotScore.svelte"
|
import WotScore from "@app/components/WotScore.svelte"
|
||||||
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
@ -53,6 +55,8 @@
|
||||||
|
|
||||||
const startEdit = () => pushModal(ProfileEdit)
|
const startEdit = () => pushModal(ProfileEdit)
|
||||||
|
|
||||||
|
const createPost = () => pushModal(NoteCreate)
|
||||||
|
|
||||||
const openChat = () => goToChat([target])
|
const openChat = () => goToChat([target])
|
||||||
|
|
||||||
const toggleFollow = async () => {
|
const toggleFollow = async () => {
|
||||||
|
|
@ -154,11 +158,17 @@
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if isSelf}
|
{#if isSelf}
|
||||||
<Button
|
<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}>
|
onclick={startEdit}>
|
||||||
<Icon icon={PenNewSquare} size={4} />
|
<Icon icon={Pen2} size={4} />
|
||||||
Edit profile
|
Edit profile
|
||||||
</Button>
|
</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}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
class="button button-neutral flex-1 sm:button-sm sm:flex-none"
|
class="button button-neutral flex-1 sm:button-sm sm:flex-none"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue