Replace the per-kind action bars with one ContentActions and give the article page its own ArticleActionBar

This commit is contained in:
Jon Staab 2026-09-25 16:24:51 -07:00
parent c710a15879
commit 9a49aa6638
26 changed files with 159 additions and 398 deletions

View file

@ -319,7 +319,7 @@ export const getStatus = (event: TrustedEvent) => reader(Classified)(event).stat
**Components.** `ClassifiedForm` builds and publishes the event, and `ClassifiedCreate` and **Components.** `ClassifiedForm` builds and publishes the event, and `ClassifiedCreate` and
`ClassifiedEdit` wrap it, supplying only the header. The list page and `ComposeMenu` open `ClassifiedEdit` wrap it, supplying only the header. The list page and `ComposeMenu` open
`ClassifiedCreate` as a modal. From a room, `ComposeMenu` sets `shareToChat`, which also quotes the `ClassifiedCreate` as a modal. From a room, `ComposeMenu` sets `shareToChat`, which also quotes the
new listing into the room. `ClassifiedActions` opens `ClassifiedEdit`. `ClassifiedItem` is the new listing into the room. Its `ContentActions` row opens `ClassifiedEdit` through `editForm`. `ClassifiedItem` is the
card, and `NoteContentClassified` renders a listing wherever `NoteContent` is used. card, and `NoteContentClassified` renders a listing wherever `NoteContent` is used.
**Routes.** `src/routes/spaces/[relay]/classifieds/+page.svelte` loads listings and their comments **Routes.** `src/routes/spaces/[relay]/classifieds/+page.svelte` loads listings and their comments

View file

@ -26,7 +26,7 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`.
| Feature | Constant (kind) | Factory | Module | Where | | Feature | Constant (kind) | Factory | Module | Where |
|---|---|---|---|---| |---|---|---|---|---|
| Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, the `*Actions` components | | Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, `ContentActions`, `CommentActions`, `ArticleActionBar` |
| Zaps | `ZAP_REQUEST` (9734), `ZAP_RECEIPT` (9735) | `ZapRequest`; receipts checked by `Zappers.validZapReceipts` | `src/app/lightning.ts` (wallets) | `Zap`, `ZapButton`, `GoalSummary`; off on iOS | | Zaps | `ZAP_REQUEST` (9734), `ZAP_RECEIPT` (9735) | `ZapRequest`; receipts checked by `Zappers.validZapReceipts` | `src/app/lightning.ts` (wallets) | `Zap`, `ZapButton`, `GoalSummary`; off on iOS |
| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenuList` | | Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenuList` |
| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenuList` | | Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenuList` |

View file

@ -238,8 +238,7 @@ New components go in the flat folder.
## Naming ## Naming
Names are `<Entity><Qualifier>`: the prefix says what it's about, the suffix what it does, which Names are `<Entity><Qualifier>`: the prefix says what it's about, the suffix what it does, which
keeps families adjacent in a flat listing. `ClassifiedActions`, `ClassifiedCreate`, keeps families adjacent in a flat listing. `ClassifiedCreate`, `ClassifiedEdit`, `ClassifiedForm`, `ClassifiedItem`, `ClassifiedStatus`.
`ClassifiedEdit`, `ClassifiedForm`, `ClassifiedItem`, `ClassifiedStatus`.
Name a modal for what it does (`SpaceJoin`, `ClassifiedCreate`, `EventDeleteConfirm`); only a Name a modal for what it does (`SpaceJoin`, `ClassifiedCreate`, `EventDeleteConfirm`); only a
handful carry a `Modal` or `Dialog` suffix. The suffix vocabulary, the prefix families, and the handful carry a `Modal` or `Dialog` suffix. The suffix vocabulary, the prefix families, and the
@ -262,7 +261,7 @@ domain reader: `RoleEdit` takes `role: RelayRoleReader`.
Identifier props are read once, `const room = $rooms.forRoom(url, h)`, since pages remount on a Identifier props are read once, `const room = $rooms.forRoom(url, h)`, since pages remount on a
param change and lists key by id. A prop that does change while mounted needs `$derived`: param change and lists key by id. A prop that does change while mounted needs `$derived`:
`ClassifiedActions` reads its event that way, because editing a listing hands it a new version. `ContentActions` reads its event that way, because editing a listing hands it a new version.
### The rest of the vocabulary ### The rest of the vocabulary
@ -374,7 +373,7 @@ onMount(() => {
The feed is built in `onMount` because the loader needs the bound scroll element from The feed is built in `onMount` because the loader needs the bound scroll element from
`<PageContent bind:element>`. `context.add` batches the reactions, comments and deletions for `<PageContent bind:element>`. `context.add` batches the reactions, comments and deletions for
every event the feed yields, rows get the same `context`, and their `*Actions` read every event the feed yields, rows get the same `context`, and their action rows read
`context.related(event)` and `context.deleted(event)`. Close the list with `<Spinner {loading}>` `context.related(event)` and `context.deleted(event)`. Close the list with `<Spinner {loading}>`
and an `{#if}` chain over loading, empty and exhausted. Calendars use and an `{#if}` chain over loading, empty and exhausted. Calendars use
`makeCalendarFeed`, which pages by date tag rather than `created_at`. `makeCalendarFeed`, which pages by date tag rather than `created_at`.
@ -417,8 +416,8 @@ return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(pro
The hosting backend isn't nostr. Toast a readable message, and `console.error` anything that The hosting backend isn't nostr. Toast a readable message, and `console.error` anything that
isn't a `HostingError`, as `HomeHosting` and `hosting/CustomDomainModal` do. Publishes are isn't a `HostingError`, as `HomeHosting` and `hosting/CustomDomainModal` do. Publishes are
optimistic, so rows show progress in place: the seven per-kind `*Actions` components wrap their optimistic, so rows show progress in place: `ContentActions`, `CommentActions` and `ArticleActionBar`
contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`. wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.
## Forms ## Forms

View file

@ -8,7 +8,7 @@ PascalCase. This is what the qualifiers mean, drawn from the ~320 components the
| Suffix | Means | Examples | | Suffix | Means | Examples |
|---|---|---| |---|---|---|
| `Item` | one row or card in a list | `ClassifiedItem`, `NoteItem`, `PeopleItem`, `ReportItem`, `RoomItem` (a message in a room), `SpaceMenuRoomItem` | | `Item` | one row or card in a list | `ClassifiedItem`, `NoteItem`, `PeopleItem`, `ReportItem`, `RoomItem` (a message in a room), `SpaceMenuRoomItem` |
| `Actions` | the footer row under an event: reactions, status, overflow menu | `ClassifiedActions`, `ThreadActions`, `GoalActions`, `EventActions` | | `Actions` | the footer row under an event: reactions, status, overflow menu | `ContentActions`, `CommentActions`, `EventActions` |
| `Menu` | the contents of a popover, taking an `onClick` that closes it | `EventMenu`, `ChatMenu`, `RoomItemMenu`, `SpaceMemberMenu` | | `Menu` | the contents of a popover, taking an `onClick` that closes it | `EventMenu`, `ChatMenu`, `RoomItemMenu`, `SpaceMemberMenu` |
| `MenuList` | the popover contents when `*Menu` is the trigger instead | `ProfileMenu` → `ProfileMenuList`, `ReportMenu` → `ReportMenuList` | | `MenuList` | the popover contents when `*Menu` is the trigger instead | `ProfileMenu` → `ProfileMenuList`, `ReportMenu` → `ReportMenuList` |
| `Mobile` | the same actions as a modal, pushed on tap instead of hovered | `RoomItemMenuMobile`, `ChatMessageMenuMobile`, `SpaceMenuMobile` | | `Mobile` | the same actions as a modal, pushed on tap instead of hovered | `RoomItemMenuMobile`, `ChatMessageMenuMobile`, `SpaceMenuMobile` |

View file

@ -332,7 +332,7 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
await openArticle(page, "Tending the Garden") await openArticle(page, "Tending the Garden")
// An article's own reactions live in the action bar under it rather than on the article itself. // An article's own reactions live in the action bar under it rather than on the article itself.
const articleActions = page.locator('[data-component="ArticleActions"]') const articleActions = page.locator('[data-component="ArticleActionBar"]')
const commentCard = comment(page, "A note about the soil") const commentCard = comment(page, "A note about the soil")
await expect(articleActions).toBeVisible() await expect(articleActions).toBeVisible()

View file

@ -48,7 +48,7 @@ const commentCard = (page: Page, text: string) =>
page.locator('[data-component="Comment"]').filter({hasText: text}) page.locator('[data-component="Comment"]').filter({hasText: text})
// The action bar under an article on its own page, which is where that article's status shows. // The action bar under an article on its own page, which is where that article's status shows.
const articleActions = (page: Page) => page.locator('[data-component="ArticleActions"]') const articleActions = (page: Page) => page.locator('[data-component="ArticleActionBar"]')
// Which relays a given event was sent to, oldest first — one entry per attempt. // Which relays a given event was sent to, oldest first — one entry per attempt.
const publishedTo = (page: Page, id: string) => const publishedTo = (page: Page, id: string) =>

View file

@ -1,38 +1,29 @@
<script lang="ts"> <script lang="ts">
import {tagSpec, tagValue} from "@welshman/util"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActionBar from "@app/components/EventActionBar.svelte"
import {makeSpacePath} from "@app/routes" import {events} from "@app/core"
import type {FeedContext} from "@app/feeds"
import {publishReaction, retractReaction} from "@app/reactions"
interface Props { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
context: FeedContext context: FeedContext
} }
const {url, event, showRoom, showActivity, context}: Props = $props() const {url, event, context}: Props = $props()
const deleted = $derived($events.isDeleted(event).$)
const h = tagValue(tagSpec("h"), event.tags)
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url}) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
const createReaction = (values: EventContent) => publishReaction(event, values, {url}) const createReaction = (values: EventContent) => publishReaction(event, values, {url})
</script> </script>
<div class="flex grow flex-wrap justify-end gap-2"> <div data-component="ArticleActionBar" class="flex w-full min-w-0 flex-col gap-3">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event} {context}> <ThunkStatusOrDeleted {event} {context}>
<ReactionSummary <ReactionSummary
{url} {url}
@ -40,10 +31,13 @@
{context} {context}
{deleteReaction} {deleteReaction}
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-top" />
{#if showActivity}
<EventActivity {event} {context} />
{/if}
<EventActions {url} {event} noun="Thread" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>
{#if !$deleted}
<EventActionBar {url} {event} noun="Article">
{#snippet leading()}
<EventActivity {event} {context} size="sm" hideLastActive />
{/snippet}
</EventActionBar>
{/if}
</div> </div>

View file

@ -1,92 +0,0 @@
<script lang="ts">
import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import EventActionBar from "@app/components/EventActionBar.svelte"
import {events, reader} from "@app/core"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
context: FeedContext
// The article's own page, where this is the primary action bar rather than a card footer.
detail?: boolean
}
const {url, event, showRoom, showActivity, context, detail}: Props = $props()
const article = $derived(reader(Article)(event))
const h = $derived(article.room())
const topics = $derived(article.topics())
const deleted = $derived($events.isDeleted(event).$)
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
</script>
{#if detail}
<div data-component="ArticleActions" class="flex w-full min-w-0 flex-col gap-3">
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-top" />
</ThunkStatusOrDeleted>
{#if !$deleted}
<EventActionBar {url} {event} noun="Article">
{#snippet leading()}
{#if showActivity}
<EventActivity {event} {context} size="sm" hideLastActive />
{/if}
{/snippet}
</EventActionBar>
{/if}
</div>
{:else}
<div data-component="ArticleActions" class="flex grow flex-wrap items-center justify-end gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)}
<button type="button" class="button button-xs rounded-full font-normal">
#{topic}
</button>
{/each}
</div>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-left" />
{#if showActivity}
<EventActivity {event} {context} />
{/if}
</ThunkStatusOrDeleted>
</div>
{#if !$deleted}
<EventActions {url} {event} noun="Article" />
{/if}
</div>
{/if}

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {formatTimestamp} from "@welshman/lib" import {formatTimestamp, uniq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util" import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain" import {Article} from "@welshman/domain"
@ -7,7 +7,7 @@
import Cv from "@lib/components/Cv.svelte" import Cv from "@lib/components/Cv.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte" import UnreadDot from "@app/components/UnreadDot.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {reader} from "@app/core" import {reader} from "@app/core"
@ -26,8 +26,15 @@
const title = $derived(article.title()) const title = $derived(article.title())
const summary = $derived(article.summary()) const summary = $derived(article.summary())
const image = $derived(article.image()) const image = $derived(article.image())
const topics = $derived(uniq(article.topics()))
</script> </script>
{#snippet topicLabels()}
{#each topics as topic (topic)}
<span class="button button-xs rounded-full font-normal">#{topic}</span>
{/each}
{/snippet}
<Cv data-component="ArticleItem" class="relative w-full card card-interactive"> <Cv data-component="ArticleItem" class="relative w-full card card-interactive">
<!-- An overlay rather than a wrapper: the card carries a profile button and the room and action <!-- An overlay rather than a wrapper: the card carries a profile button and the room and action
links, and none of those may sit inside an anchor. --> links, and none of those may sit inside an anchor. -->
@ -55,7 +62,14 @@
<ProfileLink pubkey={event.pubkey} {url} /> <ProfileLink pubkey={event.pubkey} {url} />
</span> </span>
<div class="pointer-events-auto"> <div class="pointer-events-auto">
<ArticleActions showRoom showActivity {url} {event} {context} /> <ContentActions
showRoom
showActivity
noun="Article"
labels={topics.length > 0 ? topicLabels : undefined}
{url}
{event}
{context} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -3,7 +3,8 @@
import {getAddress, tagValue, tagSpec} from "@welshman/util" import {getAddress, tagValue, tagSpec} from "@welshman/util"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte" import Cv from "@lib/components/Cv.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte" import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
@ -33,6 +34,12 @@
in <RoomLink {url} {h} /> in <RoomLink {url} {h} />
{/if} {/if}
</span> </span>
<CalendarEventActions showActivity {url} {event} {context} /> <ContentActions
showActivity
noun="Event"
editForm={CalendarEventEdit}
{url}
{event}
{context} />
</div> </div>
</Cv> </Cv>

View file

@ -1,113 +0,0 @@
<script lang="ts">
import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {Classified} from "@welshman/domain"
import {goto} from "$app/navigation"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import {normalizeTopic} from "@lib/util"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds"
import ClassifiedStatus from "@app/components/ClassifiedStatus.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import {reader, user} from "@app/core"
import {makeClassifiedPath, makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
context: FeedContext
// The grid card lays these out itself, so it opts out of both.
showTopics?: boolean
showStatus?: boolean
}
const {
url,
event,
showRoom,
showActivity,
context,
showTopics = true,
showStatus = true,
}: Props = $props()
// Editing hands this a new version of the event, so every value is recomputed rather than captured.
const classified = $derived(reader(Classified)(event))
const h = $derived(classified.room())
const topics = $derived(classified.topics() ?? [])
const editClassified = () => pushModal(ClassifiedEdit, {url, event})
const filterByTopic = (topic: string) => (e: MouseEvent) => {
e.stopPropagation()
e.preventDefault()
goto(`${makeClassifiedPath(url)}?topic=${encodeURIComponent(normalizeTopic(topic))}`)
}
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
</script>
<div class="flex grow flex-wrap items-center justify-end gap-2">
{#if (h && showRoom) || (showTopics && uniq(topics).length > 0)}
<div class="flex min-w-0 flex-wrap items-center gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
{#if showTopics}
{#each uniq(topics) as topic (topic)}
<button
type="button"
class="badge badge-neutral badge-sm cursor-pointer font-normal"
onclick={filterByTopic(topic)}>
#{normalizeTopic(topic)}
</button>
{/each}
{/if}
</div>
<span class="h-6 w-px shrink-0 bg-line"></span>
{/if}
<ThunkStatusOrDeleted {event} {context}>
{#snippet status()}
{#if showStatus}
<ClassifiedStatus {event} />
{/if}
{/snippet}
<ReactionSummary
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-left" />
{#if showActivity}
<EventActivity {event} {context} />
{/if}
<EventActions {url} {event} noun="Listing">
{#snippet customActions()}
{#if event.pubkey === $user.pubkey}
<li>
<Button onclick={editClassified}>
<Icon size={4} icon={Pen2} />
Edit Listing
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</ThunkStatusOrDeleted>
</div>

View file

@ -13,7 +13,8 @@
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte" import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
import {reader} from "@app/core" import {reader} from "@app/core"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import ClassifiedActions from "@app/components/ClassifiedActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import UnreadDot from "@app/components/UnreadDot.svelte" import UnreadDot from "@app/components/UnreadDot.svelte"
import {makeClassifiedPath} from "@app/routes" import {makeClassifiedPath} from "@app/routes"
@ -99,6 +100,6 @@
</span> </span>
<span class="truncate">{formatTimestampRelative(event.created_at)}</span> <span class="truncate">{formatTimestampRelative(event.created_at)}</span>
</span> </span>
<ClassifiedActions {url} {event} {context} showTopics={false} showStatus={false} /> <ContentActions noun="Listing" editForm={ClassifiedEdit} {url} {event} {context} />
</div> </div>
</Cv> </Cv>

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {publishReaction, retractReaction} from "@app/reactions" import {publishReaction, retractReaction} from "@app/reactions"
@ -17,11 +16,9 @@
const {url, event, showActivity = false, context}: Props = $props() const {url, event, showActivity = false, context}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const createReaction = (values: EventContent) => publishReaction(event, values, {url})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {Component, Snippet} from "svelte"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util" import {tagSpec, tagValue} from "@welshman/util"
import Pen2 from "@assets/icons/pen-2.svg?dataurl" import Pen2 from "@assets/icons/pen-2.svg?dataurl"
@ -6,60 +7,84 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {user} from "@app/core" import {user} from "@app/core"
import type {FeedContext} from "@app/feeds"
import {publishReaction, retractReaction} from "@app/reactions"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type EditForm = Component<{url: string; event: TrustedEvent}>
type Props = { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
context: FeedContext
noun: string
showRoom?: boolean showRoom?: boolean
showActivity?: boolean showActivity?: boolean
context: FeedContext hideZap?: boolean
editForm?: EditForm
labels?: Snippet
status?: Snippet
} }
const {url, event, showRoom, showActivity, context}: Props = $props() const {
url,
event,
context,
noun,
showRoom,
showActivity,
hideZap,
editForm,
labels,
status,
}: Props = $props()
const h = $derived(tagValue(tagSpec("h"), event.tags)) const h = $derived(tagValue(tagSpec("h"), event.tags))
const editEvent = () => pushModal(CalendarEventEdit, {url, event}) const edit = (form: EditForm) => () => pushModal(form, {url, event})
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h}) const createReaction = (values: EventContent) => publishReaction(event, values, {url})
</script> </script>
<div class="flex grow flex-wrap justify-end gap-2"> <div class="flex grow flex-wrap items-center justify-end gap-2">
{#if (h && showRoom) || labels}
<div class="flex min-w-0 flex-wrap items-center gap-2">
{#if h && showRoom} {#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full"> <Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} /> Posted in #<RoomName {h} {url} />
</Link> </Link>
{/if} {/if}
<ThunkStatusOrDeleted {event} {context}> {@render labels?.()}
</div>
<span class="h-6 w-px shrink-0 bg-line"></span>
{/if}
<ThunkStatusOrDeleted {event} {context} {status}>
<ReactionSummary <ReactionSummary
{url} {url}
{event} {event}
{context} {context}
{deleteReaction} {deleteReaction}
{createReaction} {createReaction}
reactionClass="tip tip-left" /> hideZaps={hideZap}
reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {event} {context} /> <EventActivity {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Event"> <EventActions {url} {event} {hideZap} {noun}>
{#snippet customActions()} {#snippet customActions()}
{#if event.pubkey === $user.pubkey} {#if editForm && event.pubkey === $user.pubkey}
<li> <li>
<Button onclick={editEvent}> <Button onclick={edit(editForm)}>
<Icon size={4} icon={Pen2} /> <Icon size={4} icon={Pen2} />
Edit Event Edit {noun}
</Button> </Button>
</li> </li>
{/if} {/if}

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -18,9 +17,7 @@
const {url, event, hideZap, class: className = ""}: Props = $props() const {url, event, hideZap, class: className = ""}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) const onEmoji = (emoji: string) => publishReaction(event, {content: emoji, tags: []}, {url})
const onEmoji = (emoji: string) => publishReaction(event, {content: emoji, tags: []}, {url, h})
</script> </script>
{#if ENABLE_ZAPS && !hideZap} {#if ENABLE_ZAPS && !hideZap}

View file

@ -1,54 +0,0 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {ZapGoal} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
import {reader} from "@app/core"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds"
import {publishReaction, retractReaction} from "@app/reactions"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {makeSpacePath} from "@app/routes"
interface Props {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
context: FeedContext
}
const {url, event, showRoom, showActivity, context}: Props = $props()
const goal = reader(ZapGoal)(event)
const h = goal.room()
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
const createReaction = (values: EventContent) => publishReaction(event, values, {url})
</script>
<div class="flex min-w-0 grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
hideZaps
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-left" />
{#if showActivity}
<EventActivity {event} {context} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</ThunkStatusOrDeleted>
</div>

View file

@ -7,7 +7,7 @@
import {reader} from "@app/core" import {reader} from "@app/core"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import type {GoalProgress} from "@app/goals" import type {GoalProgress} from "@app/goals"
import GoalSummary from "@app/components/GoalSummary.svelte" import GoalSummary from "@app/components/GoalSummary.svelte"
@ -57,6 +57,6 @@
in <RoomLink {url} {h} /> in <RoomLink {url} {h} />
{/if} {/if}
</span> </span>
<GoalActions showActivity {url} {event} {context} /> <ContentActions showActivity hideZap noun="Goal" {url} {event} {context} />
</div> </div>
</Cv> </Cv>

View file

@ -1,50 +0,0 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import type {FeedContext} from "@app/feeds"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
context: FeedContext
}
const {url, event, showRoom, showActivity, context}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags)
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-left" />
{#if showActivity}
<EventActivity {event} {context} />
{/if}
<EventActions {url} {event} noun="Poll" />
</ThunkStatusOrDeleted>
</div>

View file

@ -4,7 +4,7 @@
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte" import Cv from "@lib/components/Cv.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import PollActions from "@app/components/PollActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
@ -33,6 +33,6 @@
in <RoomLink {url} {h} /> in <RoomLink {url} {h} />
{/if} {/if}
</span> </span>
<PollActions showActivity {url} {event} {context} /> <ContentActions showActivity noun="Poll" {url} {event} {context} />
</div> </div>
</Cv> </Cv>

View file

@ -60,7 +60,6 @@
onEdit, onEdit,
}: Props = $props() }: Props = $props()
const h = tagValue(tagSpec("h"), event.tags)
const today = formatTimestampAsDate(now()) const today = formatTimestampAsDate(now())
const profileDisplay = $profiles.display(event.pubkey, [url]).$ const profileDisplay = $profiles.display(event.pubkey, [url]).$
const thunk = $derived($thunks.merge($thunksByEventId.get(event.id) ?? noThunks)) const thunk = $derived($thunks.merge($thunksByEventId.get(event.id) ?? noThunks))
@ -92,9 +91,9 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h}) const createReaction = (values: EventContent) => publishReaction(event, values, {url})
</script> </script>
<TapTarget <TapTarget

View file

@ -13,7 +13,7 @@
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte" import CommentActions from "@app/components/CommentActions.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import ThreadActions from "@app/components/ThreadActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import {handles, profiles} from "@app/core" import {handles, profiles} from "@app/core"
import {highlightedEvent, makeEventPermalink} from "@app/routes" import {highlightedEvent, makeEventPermalink} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -97,7 +97,7 @@
{#if isComment} {#if isComment}
<CommentActions {event} {url} {context} /> <CommentActions {event} {url} {context} />
{:else} {:else}
<ThreadActions {event} {url} {context} /> <ContentActions noun="Thread" {event} {url} {context} />
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -1,10 +1,10 @@
import {removeUndefined} from "@welshman/lib" import {removeUndefined} from "@welshman/lib"
import {tagSpec, tagValue} from "@welshman/util"
import type {EventContent, TrustedEvent} from "@welshman/util" import type {EventContent, TrustedEvent} from "@welshman/util"
import {deletes, reactions, relays, wraps} from "@app/core" import {deletes, reactions, relays, wraps} from "@app/core"
export type ReactionTarget = { export type ReactionTarget = {
url?: string url?: string
h?: string
// Where to publish, when it isn't just the space's own relay. // Where to publish, when it isn't just the space's own relay.
urls?: string[] urls?: string[]
} }
@ -13,12 +13,14 @@ export type ReactionTarget = {
export const publishReaction = async ( export const publishReaction = async (
event: TrustedEvent, event: TrustedEvent,
{content, tags}: EventContent, {content, tags}: EventContent,
{url, h, urls}: ReactionTarget, {url, urls}: ReactionTarget,
) => { ) => {
const protect = url ? await relays.get().hasNip(url, 70) : false const protect = url ? await relays.get().hasNip(url, 70) : false
const h = tagValue(tagSpec("h"), event.tags)
const command = await reactions.get().react(event, content, writer => { const command = await reactions.get().react(event, content, writer => {
writer.addTags(...tags).setProtected(protect) writer.addTags(...tags).setProtected(protect)
// welshman tags the room only once the tracker knows the target's relay, which an unconfirmed message lacks.
if (url && h) { if (url && h) {
writer.setRoom(url, h) writer.setRoom(url, h)
} }

View file

@ -11,7 +11,7 @@
import ContentMarkdown from "@app/components/ContentMarkdown.svelte" import ContentMarkdown from "@app/components/ContentMarkdown.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte" import ArticleActionBar from "@app/components/ArticleActionBar.svelte"
import ArticleAuthorSidebar from "@app/components/ArticleAuthorSidebar.svelte" import ArticleAuthorSidebar from "@app/components/ArticleAuthorSidebar.svelte"
import EventComments from "@app/components/EventComments.svelte" import EventComments from "@app/components/EventComments.svelte"
import EventMenu from "@app/components/EventMenu.svelte" import EventMenu from "@app/components/EventMenu.svelte"
@ -117,7 +117,7 @@
</article> </article>
<div class="border-t px-5 py-4 sm:px-8" style="border-color: var(--line)"> <div class="border-t px-5 py-4 sm:px-8" style="border-color: var(--line)">
<div class="mx-auto flex w-full max-w-[68ch]"> <div class="mx-auto flex w-full max-w-[68ch]">
<ArticleActions event={$event} {url} {context} showActivity detail /> <ArticleActionBar event={$event} {url} {context} />
</div> </div>
</div> </div>
<!-- lg carries this in the sidebar instead, which sits below the comments here. --> <!-- lg carries this in the sidebar instead, which sits below the comments here. -->

View file

@ -15,7 +15,8 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte" import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte" import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte"
import CalendarEventDate from "@app/components/CalendarEventDate.svelte" import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
@ -92,7 +93,13 @@
<CalendarEventHeader event={$event} /> <CalendarEventHeader event={$event} />
<CalendarEventMeta event={$event} {url} /> <CalendarEventMeta event={$event} {url} />
<CalendarRsvp {url} event={$event} rsvps={$rsvps} onShowPeople={showTab("people")} /> <CalendarRsvp {url} event={$event} rsvps={$rsvps} onShowPeople={showTab("people")} />
<CalendarEventActions showRoom {url} event={$event} {context} /> <ContentActions
showRoom
noun="Event"
editForm={CalendarEventEdit}
{url}
event={$event}
{context} />
</div> </div>
</div> </div>
<!-- One event carries three unrelated conversations, so give each its own tab instead of <!-- One event carries three unrelated conversations, so give each its own tab instead of

View file

@ -1,18 +1,23 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {sleep} from "@welshman/lib" import {sleep, uniq} from "@welshman/lib"
import {Classified} from "@welshman/domain" import {Classified} from "@welshman/domain"
import {normalizeTopic} from "@lib/util"
import Link from "@lib/components/Link.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 NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import ClassifiedActions from "@app/components/ClassifiedActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import ClassifiedStatus from "@app/components/ClassifiedStatus.svelte"
import EventComments from "@app/components/EventComments.svelte" import EventComments from "@app/components/EventComments.svelte"
import {events, reader} from "@app/core" import {events, reader} from "@app/core"
import {makeFeedContext} from "@app/feeds" import {makeFeedContext} from "@app/feeds"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeClassifiedPath} from "@app/routes"
import type {PageProps} from "./$types" import type {PageProps} from "./$types"
const {params}: PageProps = $props() const {params}: PageProps = $props()
@ -23,11 +28,23 @@
const event = $events.one(address, [url]).$ const event = $events.one(address, [url]).$
const classified = derived(event, $event => ($event ? reader(Classified)($event) : undefined)) const classified = derived(event, $event => ($event ? reader(Classified)($event) : undefined))
const topics = $derived(uniq(($classified?.topics() ?? []).map(normalizeTopic)))
const back = () => history.back() const back = () => history.back()
onDestroy(context.cleanup) onDestroy(context.cleanup)
</script> </script>
{#snippet topicLinks()}
{#each topics as topic (topic)}
<Link
href="{makeClassifiedPath(url)}?topic={encodeURIComponent(topic)}"
class="badge badge-neutral badge-sm font-normal">
#{topic}
</Link>
{/each}
{/snippet}
<SpaceBar {back}> <SpaceBar {back}>
{#snippet title()} {#snippet title()}
<h1 class="text-xl">{$classified?.title() ?? ""}</h1> <h1 class="text-xl">{$classified?.title() ?? ""}</h1>
@ -39,7 +56,18 @@
<NoteCard event={$event} {url} class="card z-feature w-full"> <NoteCard event={$event} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12"> <div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={$event} {url} /> <NoteContent showEntire event={$event} {url} />
<ClassifiedActions showRoom event={$event} {url} {context} /> <ContentActions
showRoom
noun="Listing"
editForm={ClassifiedEdit}
labels={topics.length > 0 ? topicLinks : undefined}
event={$event}
{url}
{context}>
{#snippet status()}
<ClassifiedStatus event={$event} />
{/snippet}
</ContentActions>
</div> </div>
</NoteCard> </NoteCard>
<EventComments event={$event} {url} {context} /> <EventComments event={$event} {url} {context} />

View file

@ -13,7 +13,7 @@
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import ZapButton from "@app/components/ZapButton.svelte" import ZapButton from "@app/components/ZapButton.svelte"
import GoalActions from "@app/components/GoalActions.svelte" import ContentActions from "@app/components/ContentActions.svelte"
import GoalMeter from "@app/components/GoalMeter.svelte" import GoalMeter from "@app/components/GoalMeter.svelte"
import GoalStatus from "@app/components/GoalStatus.svelte" import GoalStatus from "@app/components/GoalStatus.svelte"
import GoalSupporters from "@app/components/GoalSupporters.svelte" import GoalSupporters from "@app/components/GoalSupporters.svelte"
@ -104,7 +104,7 @@
{$progress.isFunded ? "Chip in anyway" : "Contribute to this goal"} {$progress.isFunded ? "Chip in anyway" : "Contribute to this goal"}
</ZapButton> </ZapButton>
{/if} {/if}
<GoalActions event={$event} {url} {context} /> <ContentActions hideZap noun="Goal" event={$event} {url} {context} />
</div> </div>
</NoteCard> </NoteCard>
{#if $goal.summary()} {#if $goal.summary()}