Move goal aggregation, the article form, space search ranking and detail-page loading fallbacks out of routes

This commit is contained in:
Jon Staab 2026-09-25 23:27:46 -07:00
parent e5a270aa5c
commit a9ea5e34fe
20 changed files with 552 additions and 515 deletions

View file

@ -331,7 +331,8 @@ with `makeFeed` and filters them with the `classifieds.ts` helpers. `[address]/+
one listing with `$events.one(address, [url]).$`. one listing with `$events.one(address, [url]).$`.
Articles are composed on a full page (`spaces/[relay]/articles/create`, built by Articles are composed on a full page (`spaces/[relay]/articles/create`, built by
`makeArticleCreatePath`) instead of in a modal. `makeArticleCreatePath`) instead of in a modal. The page reads `?h=` and `?shareToChat` and
renders `ArticleForm`.
## Related skills ## Related skills

View file

@ -121,6 +121,7 @@ they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores.
| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL | | `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL |
| `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` | | `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` |
| `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) | | `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) |
| `GoalProgresses` (`goals.ts`) | none | one memoized NIP-75 progress store per goal and relay, behind `deriveGoalProgress` |
| `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view | | `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view |
Each is exposed with `usePlugin`. `Statuses` is the minimal shape: Each is exposed with `usePlugin`. `Statuses` is the minimal shape:

View file

@ -229,8 +229,8 @@ lives there:
`ModalSubtitle`, `ModalFooter`, `Confirm` `ModalSubtitle`, `ModalFooter`, `Confirm`
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`, - controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons` `DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`, `Cv`, - display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `LoadingFallback`, `Tooltip`,
`VirtualList`, `Masonry`, `DragList`, `ScrollToTop` `Tippy`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens - the CSS component families (`button.css`, `card.css`, …) and the theme tokens
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
@ -324,14 +324,13 @@ data every page needs lives in `src/app/sync.ts`.
lists before loading the author's outbox with `$network.loadLenient`. lists before loading the author's outbox with `$network.loadLenient`.
**A detail page:** `$events.one(address, [url]).$` loads when nothing local matches. While it is **A detail page:** `$events.one(address, [url]).$` loads when nothing local matches. While it is
empty, seven pages show a spinner that turns into a failure message: empty, seven pages show `LoadingFallback`, a spinner that turns into a failure message after
five seconds:
```svelte ```svelte
{#await sleep(5000)} <LoadingFallback
<Spinner loading>Loading listing...</Spinner> loadingMessage="Loading listing..."
{:then} failureMessage="Failed to load classified listing." />
<p>Failed to load classified listing.</p>
{/await}
``` ```
**Related events:** request them, abort on teardown, and read them back from the repository with **Related events:** request them, abort on teardown, and read them back from the repository with
@ -444,7 +443,9 @@ Create/edit pairs come in three shapes:
- **The form owns the mutation and a draft.** `ClassifiedForm` persists fields with `DraftKey` - **The form owns the mutation and a draft.** `ClassifiedForm` persists fields with `DraftKey`
and republishes the same `d` on edit. `ClassifiedCreate` passes a header, and `ClassifiedEdit` and republishes the same `d` on edit. `ClassifiedCreate` passes a header, and `ClassifiedEdit`
turns a reader into `initialValues`. Its `Values` type stays local, so `ClassifiedEdit` turns a reader into `initialValues`. Its `Values` type stays local, so `ClassifiedEdit`
restates the shape; export it instead. restates the shape; export it instead, as `ArticleForm` does. `ArticleForm` is the same shape
on a full page: it renders the `SpaceBar` with its Publish button, takes the title as `header`,
and lands on the published article.
Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`. Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.

View file

@ -0,0 +1,270 @@
<script lang="ts" module>
export type Values = {
d: string
title: string
summary: string
image: string
topics: string[]
content: string | object
}
</script>
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {now, randomId} from "@welshman/lib"
import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain"
import {isMobile} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import DocumentText from "@assets/icons/document-text.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {publishToSpace} from "@app/rooms"
import {makeArticlePath} from "@app/routes"
import {compressFileForUpload, uploadFile} from "@app/uploads"
import {pushToast} from "@app/toast"
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
}
const {url, h, shareToChat = false, header}: Props = $props()
const draftKey = new DraftKey<Values>(`article:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const coverInputId = randomId()
const uploading = writable(false)
const back = () => goto(makeArticlePath(url))
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const clearCover = () => {
image = ""
}
const onCoverChange = async (event: Event & {currentTarget: HTMLInputElement}) => {
const input = event.currentTarget
const file = input.files?.[0]
// Clear the input so picking the same file again after removing the cover still fires
input.value = ""
if (file) {
uploadingCover = true
try {
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
if (result?.url) {
image = result.url
} else {
pushToast({theme: "error", message: error ?? "Failed to upload your cover image."})
}
} catch (e) {
// compressFileForUpload rejects outside uploadFile's own error handling
console.error("Error caught when uploading cover image:", e)
pushToast({theme: "error", message: "Failed to upload your cover image."})
} finally {
uploadingCover = false
}
}
}
const submit = async () => {
if ($uploading || uploadingCover || loading) {
return
}
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your article.",
})
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({
theme: "error",
message: "Please write something for your article.",
})
}
loading = true
try {
const eventWriter = writer(Article)
.setIdentifier(d)
.setContent(content)
.setTitle(title)
.setTopics(topics)
.setPublishedAt(now())
.addTags(...ed.storage.nostr.getEditorTags())
if (summary) {
eventWriter.setSummary(summary)
}
if (image) {
eventWriter.setImage(image)
}
const {thunk, error} = await publishToSpace(eventWriter, {url, h, shareToChat})
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
goto(makeArticlePath(url, getAddress(thunk.options.event)))
} finally {
loading = false
}
}
let loading = $state(false)
let uploadingCover = $state(false)
let titleInput = $state<HTMLInputElement>()
// The autofocus attribute is ignored while the button that navigated here still holds focus
onMount(() => {
if (!isMobile) {
titleInput?.focus()
}
})
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let summary = $state(initialValues?.summary ?? "")
let image = $state(initialValues?.image ?? "")
let topics = $state(initialValues?.topics ?? [])
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "Write your article...",
content,
})
$effect(() => {
draftKey.set({d, title, summary, image, topics, content})
})
</script>
<SpaceBar {back}>
{#snippet leading()}
<Icon icon={DocumentText} />
{/snippet}
{#snippet title()}
{@render header()}
{/snippet}
{#snippet action()}
<Button
class="button button-primary button-sm"
onclick={submit}
disabled={$uploading || uploadingCover || loading}>
<Spinner {loading}>Publish</Spinner>
</Button>
{/snippet}
</SpaceBar>
<!-- column flex: justify-center would put the top of a tall page out of scroll reach -->
<PageContent class="items-center">
<div class="flex w-full max-w-3xl flex-col gap-6">
<input
id={coverInputId}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onchange={onCoverChange}
class="hidden" />
{#if image}
<div class="relative">
<img src={image} alt="Article cover" class="h-56 w-full rounded-2xl object-cover" />
<Button
data-tip="Remove cover image"
aria-label="Remove cover image"
class="tip tip-left button button-neutral button-sm button-circle absolute right-2 top-2"
onclick={clearCover}>
<Icon icon={CloseCircle} />
</Button>
</div>
{:else}
<label
for={coverInputId}
class="flex h-28 cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed border-line text-content-muted transition-colors hover:text-content">
{#if uploadingCover}
<Spinner loading>Uploading cover image...</Spinner>
{:else}
<Icon icon={GallerySend} />
Add a cover image
{/if}
</label>
{/if}
<div class="flex flex-col gap-2">
<input
bind:this={titleInput}
bind:value={title}
class="bg-transparent text-3xl font-bold outline-none placeholder:text-content-muted"
type="text"
placeholder="Title" />
<input
bind:value={summary}
class="placeholder:text-content-muted bg-transparent text-lg outline-none"
type="text"
placeholder="Add a one line teaser for the article list" />
</div>
<div class="relative">
<div class="article-editor note-editor">
<EditorContent {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>
<Field>
{#snippet label()}
<p>Topics</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
{/snippet}
</Field>
</div>
</PageContent>

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import type {Readable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {ZapGoal} from "@welshman/domain" import {ZapGoal} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
@ -9,7 +8,6 @@
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import ContentActions from "@app/components/ContentActions.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 GoalSummary from "@app/components/GoalSummary.svelte" import GoalSummary from "@app/components/GoalSummary.svelte"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte" import UnreadDot from "@app/components/UnreadDot.svelte"
@ -19,10 +17,9 @@
url: string url: string
event: TrustedEvent event: TrustedEvent
context: FeedContext context: FeedContext
progress?: Readable<GoalProgress>
} }
const {url, event, context, progress}: Props = $props() const {url, event, context}: Props = $props()
const goal = reader(ZapGoal)(event) const goal = reader(ZapGoal)(event)
@ -49,7 +46,7 @@
maxLength={180} /> maxLength={180} />
</div> </div>
{/if} {/if}
<GoalSummary {url} {event} {progress} /> <GoalSummary {url} {event} />
<div class="flex w-full min-w-0 flex-wrap items-center justify-between gap-2"> <div class="flex w-full min-w-0 flex-wrap items-center justify-between gap-2">
<span class="min-w-0 py-1 text-sm text-content-muted"> <span class="min-w-0 py-1 text-sm text-content-muted">
by <ProfileLink pubkey={event.pubkey} {url} /> by <ProfileLink pubkey={event.pubkey} {url} />

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import type {Readable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -10,19 +9,16 @@
import GoalStatus from "@app/components/GoalStatus.svelte" import GoalStatus from "@app/components/GoalStatus.svelte"
import {ENABLE_ZAPS} from "@app/env" import {ENABLE_ZAPS} from "@app/env"
import {deriveGoalProgress} from "@app/goals" import {deriveGoalProgress} from "@app/goals"
import type {GoalProgress} from "@app/goals"
type Props = { type Props = {
url?: string url?: string
event: TrustedEvent event: TrustedEvent
progress?: Readable<GoalProgress>
class?: string class?: string
} }
const {url, event, progress: providedProgress, ...props}: Props = $props() const {url, event, ...props}: Props = $props()
// The list page already has a store per goal; reuse it instead of resubscribing. const progress = deriveGoalProgress(event, url)
const progress = providedProgress ?? deriveGoalProgress(event, url)
const backers = $derived($progress.backers.length) const backers = $derived($progress.backers.length)
</script> </script>

View file

@ -1,14 +1,13 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import Spinner from "@lib/components/Spinner.svelte" import LoadingFallback from "@lib/components/LoadingFallback.svelte"
import CommentCompose from "@app/components/CommentCompose.svelte" import CommentCompose from "@app/components/CommentCompose.svelte"
import CommentTree from "@app/components/CommentTree.svelte" import CommentTree from "@app/components/CommentTree.svelte"
import NoteItem from "@app/components/NoteItem.svelte" import NoteItem from "@app/components/NoteItem.svelte"
@ -69,13 +68,7 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="flex justify-center py-20"> <LoadingFallback loadingMessage="Loading note..." failureMessage="Failed to load note." />
{#await sleep(5000)}
<Spinner loading>Loading note...</Spinner>
{:then}
<p>Failed to load note.</p>
{/await}
</div>
{/if} {/if}
</ModalBody> </ModalBody>
</Modal> </Modal>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import RelaySummary from "@app/components/RelaySummary.svelte"
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import {userSpaceUrls} from "@app/rooms"
import {pushModal} from "@app/modal"
import {goToSpace} from "@app/routes"
type Props = {
url: string
invite?: string
}
const {url, invite}: Props = $props()
const open = () => {
if ($userSpaceUrls.includes(url)) {
goToSpace(url)
} else if (invite) {
pushModal(SpaceInviteAccept, {invite})
} else {
pushModal(SpaceJoin, {url})
}
}
</script>
<Button class="card card-interactive" onclick={open}>
<RelaySummary {url} />
</Button>

View file

@ -1,12 +1,13 @@
import {derived} from "svelte/store" import {derived, readable} from "svelte/store"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {now, removeUndefined} from "@welshman/lib" import {now, removeUndefined, sortBy} from "@welshman/lib"
import {fromMsats} from "@welshman/util" import {fromMsats} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {ZapGoal} from "@welshman/domain" import {ZapGoal} from "@welshman/domain"
import type {Zap} from "@welshman/domain" import type {Zap} from "@welshman/domain"
import {ZapGoals} from "@welshman/app" import {Domain, ZapGoals} from "@welshman/app"
import {app, reader} from "@app/core" import type {IApp} from "@welshman/app"
import {app} from "@app/core"
export type GoalProgress = { export type GoalProgress = {
zaps: Zap[] zaps: Zap[]
@ -19,22 +20,118 @@ export type GoalProgress = {
closedAt?: number closedAt?: number
} }
// Welshman tallies a goal in millisats, and comparing there avoids funding a rounded sat early. export type GoalFilter = "all" | "live" | "funded" | "ended"
export const deriveGoalProgress = (event: TrustedEvent, url?: string): Readable<GoalProgress> => {
const closedAt = reader(ZapGoal)(event).closedAt()
const progress = app
.get()
.use(ZapGoals)
.progress(event, removeUndefined([url])).$
return derived(progress, $progress => ({ export type GoalSort = "active" | "new" | "progress" | "ending"
zaps: $progress.zaps,
raised: fromMsats($progress.amount), export class GoalProgresses {
target: fromMsats($progress.target), private progressByKey = new Map<string, Readable<GoalProgress>>()
percent: $progress.target > 0 ? Math.round(($progress.amount / $progress.target) * 100) : 0,
backers: $progress.contributors, constructor(private readonly app: IApp) {}
isFunded: $progress.target > 0 && $progress.amount >= $progress.target,
isEnded: Boolean(closedAt && closedAt < now()), forGoal = (event: TrustedEvent, url?: string) => {
closedAt, const key = `${event.id}:${url ?? ""}`
})) let progress = this.progressByKey.get(key)
if (!progress) {
progress = this.derive(event, url)
this.progressByKey.set(key, progress)
}
return progress
}
// Welshman tallies a goal in millisats, and comparing there avoids funding a rounded sat early.
private derive = (event: TrustedEvent, url?: string): Readable<GoalProgress> => {
const closedAt = this.app.use(Domain).reader(ZapGoal)(event).closedAt()
const progress = this.app.use(ZapGoals).progress(event, removeUndefined([url])).$
return derived(progress, $progress => ({
zaps: $progress.zaps,
raised: fromMsats($progress.amount),
target: fromMsats($progress.target),
percent: $progress.target > 0 ? Math.round(($progress.amount / $progress.target) * 100) : 0,
backers: $progress.contributors,
isFunded: $progress.target > 0 && $progress.amount >= $progress.target,
isEnded: Boolean(closedAt && closedAt < now()),
closedAt,
}))
}
}
export const deriveGoalProgress = (event: TrustedEvent, url?: string) =>
app.get().use(GoalProgresses).forGoal(event, url)
// `derived` over no stores never fires, so an empty board needs a store of its own.
export const deriveGoalProgressById = (goals: TrustedEvent[], url: string) =>
goals.length > 0
? derived(
goals.map(event => deriveGoalProgress(event, url)),
progresses => new Map(progresses.map((progress, i) => [goals[i]!.id, progress])),
)
: readable(new Map<string, GoalProgress>())
export const getGoalTotals = (progressById: Map<string, GoalProgress>) => {
const backers = new Set<string>()
let raised = 0
let funded = 0
for (const progress of progressById.values()) {
raised += progress.raised
funded += progress.isFunded ? 1 : 0
for (const backer of progress.backers) {
backers.add(backer)
}
}
return {raised, funded, backers: backers.size}
}
export const filterGoals = (
filter: GoalFilter,
goals: TrustedEvent[],
progressById: Map<string, GoalProgress>,
) =>
goals.filter(event => {
const progress = progressById.get(event.id)
if (filter === "live") {
return Boolean(progress && !progress.isEnded && !progress.isFunded)
}
if (filter === "funded") {
return Boolean(progress?.isFunded)
}
if (filter === "ended") {
return Boolean(progress?.isEnded)
}
return true
})
export const sortGoals = (
sort: GoalSort,
goals: TrustedEvent[],
progressById: Map<string, GoalProgress>,
activeAt: Map<string, number>,
) => {
if (sort === "new") {
return sortBy(event => -event.created_at, goals)
}
if (sort === "progress") {
return sortBy(event => -(progressById.get(event.id)?.percent ?? 0), goals)
}
if (sort === "ending") {
return sortBy(event => {
const closedAt = progressById.get(event.id)?.closedAt
return closedAt && closedAt > now() ? closedAt : Infinity
}, goals)
}
return sortBy(event => -(activeAt.get(event.id) ?? event.created_at), goals)
} }

View file

@ -1,6 +1,6 @@
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {derived, get} from "svelte/store" import {derived, get} from "svelte/store"
import {formatTimestampAsDate, int, sortBy, uniq, MINUTE} from "@welshman/lib" import {addToMapKey, dec, formatTimestampAsDate, int, sortBy, uniq, MINUTE} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import { import {
ROOM_DELETE_EVENT, ROOM_DELETE_EVENT,
@ -15,7 +15,8 @@ import {
} from "@welshman/util" } from "@welshman/util"
import type {EventContent, TrustedEvent} from "@welshman/util" import type {EventContent, TrustedEvent} from "@welshman/util"
import {Comment, Message} from "@welshman/domain" import {Comment, Message} from "@welshman/domain"
import type {BaseEventReader, EventWriter} from "@welshman/domain" import type {BaseEventReader, EventWriter, Relay} from "@welshman/domain"
import {throttled} from "@welshman/store"
import {MembershipStatus, RoomLists, makeRoomKey, createSearch, publish} from "@welshman/app" import {MembershipStatus, RoomLists, makeRoomKey, createSearch, publish} from "@welshman/app"
import type {Room, RoomMeta} from "@welshman/app" import type {Room, RoomMeta} from "@welshman/app"
import { import {
@ -266,6 +267,45 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) =>
([$status, $isAdmin]) => ($isAdmin ? MembershipStatus.Granted : $status), ([$status, $isAdmin]) => ($isAdmin ? MembershipStatus.Granted : $status),
) )
// How many people list each space both scopes and ranks the search.
export const deriveSpaceSearch = () => {
const spacePubkeysByUrl = derived(roomLists.get().all.$, $roomLists => {
const result = new Map<string, Set<string>>()
for (const roomList of $roomLists) {
for (const url of roomList.urls()) {
addToMapKey(result, url, roomList.author())
}
}
return result
})
return derived(
[throttled(1000, relays.get().all.$), spacePubkeysByUrl],
([$relays, $spacePubkeysByUrl]) =>
createSearch(
$relays.filter(r => $spacePubkeysByUrl.has(r.url)),
{
getValue: (relay: Relay) => relay.url,
sortFn: ({score, item}) => {
if (score && score > 0.1) {
return -score
}
const wotScore = $spacePubkeysByUrl.get(item.url)?.size || 0
return score ? dec(score) * wotScore : -wotScore
},
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
shouldSort: false,
},
},
),
)
}
export const deriveUserRoomSearch = () => export const deriveUserRoomSearch = () =>
derived( derived(
[userSpaceUrls, userRoomList, rooms.get().byUrl.$], [userSpaceUrls, userRoomList, rooms.get().byUrl.$],

View file

@ -0,0 +1,21 @@
<script lang="ts">
import cx from "classnames"
import {sleep} from "@welshman/lib"
import Spinner from "@lib/components/Spinner.svelte"
type Props = {
loadingMessage: string
failureMessage: string
class?: string
}
const {loadingMessage, failureMessage, ...props}: Props = $props()
</script>
<div class={cx("flex justify-center py-20", props.class)}>
{#await sleep(5000)}
<Spinner loading>{loadingMessage}</Spinner>
{:then}
<p>{failureMessage}</p>
{/await}
</div>

View file

@ -1,11 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {derived as _derived} from "svelte/store" import {sleep} from "@welshman/lib"
import {addToMapKey, dec, sleep} from "@welshman/lib"
import {ROOMS} from "@welshman/util" import {ROOMS} from "@welshman/util"
import type {Relay} from "@welshman/domain" import {Sync} from "@welshman/app"
import {throttled} from "@welshman/store"
import {Sync, createSearch} from "@welshman/app"
import {createScroller, isMobile} from "@lib/html" import {createScroller, isMobile} from "@lib/html"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Compass from "@assets/icons/compass.svg?dataurl" import Compass from "@assets/icons/compass.svg?dataurl"
@ -19,75 +16,26 @@
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte" import ContentSearch from "@lib/components/ContentSearch.svelte"
import RelaySummary from "@app/components/RelaySummary.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte" import SpaceResult from "@app/components/SpaceResult.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte" import {app, roomLists, user} from "@app/core"
import {app, relays, roomLists, user} from "@app/core" import {deriveSpaceSearch, userSpaceUrls} from "@app/rooms"
import {userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env" import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
import {bootstrapPubkeys} from "@app/social" import {bootstrapPubkeys} from "@app/social"
import {parseInviteLink} from "@app/access" import {parseInviteLink} from "@app/access"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {goToSpace} from "@app/routes"
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
const userSpacesLoaded = $roomLists.load($user.pubkey) const userSpacesLoaded = $roomLists.load($user.pubkey)
const spaceSearch = deriveSpaceSearch()
// How many people list each space, which both scopes and ranks search.
const spacePubkeysByUrl = _derived($roomLists.all.$, $roomLists => {
const result = new Map<string, Set<string>>()
for (const roomList of $roomLists) {
for (const url of roomList.urls()) {
addToMapKey(result, url, roomList.author())
}
}
return result
})
const relaySearch = _derived(
[throttled(1000, $relays.all.$), spacePubkeysByUrl],
([$relays, $spacePubkeysByUrl]) => {
const options = $relays.filter(r => $spacePubkeysByUrl.has(r.url))
return createSearch(options, {
getValue: (relay: Relay) => relay.url,
sortFn: ({score, item}) => {
if (score && score > 0.1) {
return -score!
}
const wotScore = $spacePubkeysByUrl.get(item.url)?.size || 0
return score ? dec(score) * wotScore : -wotScore
},
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
shouldSort: false,
},
})
},
)
const openSpace = (url: string, claim = "") => {
if ($userSpaceUrls.includes(url)) {
goToSpace(url)
} else if (claim) {
pushModal(SpaceInviteAccept, {invite: term})
} else {
pushModal(SpaceJoin, {url})
}
}
let term = $state("") let term = $state("")
let limit = $state(20) let limit = $state(20)
let element: Element let element: Element
const inviteData = $derived(parseInviteLink(term)) const inviteData = $derived(parseInviteLink(term))
const searchResults = $derived($relaySearch.searchOptions(term)) const searchResults = $derived($spaceSearch.searchOptions(term))
const userSpaceSet = $derived(new Set($userSpaceUrls)) const userSpaceSet = $derived(new Set($userSpaceUrls))
const otherSpaces = $derived( const otherSpaces = $derived(
searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url), searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url),
@ -146,9 +94,7 @@
{#snippet content()} {#snippet content()}
<div class="flex flex-col gap-2" bind:this={element}> <div class="flex flex-col gap-2" bind:this={element}>
{#each PLATFORM_RELAYS as url (url)} {#each PLATFORM_RELAYS as url (url)}
<Button class="card card-interactive" onclick={() => openSpace(url)}> <SpaceResult {url} />
<RelaySummary {url} />
</Button>
{:else} {:else}
{#await userSpacesLoaded} {#await userSpacesLoaded}
<div class="flex items-center justify-center py-20"> <div class="flex items-center justify-center py-20">
@ -159,20 +105,14 @@
{#if inviteData} {#if inviteData}
<Divider>Search results</Divider> <Divider>Search results</Divider>
{#key inviteData.url} {#key inviteData.url}
<Button <SpaceResult url={inviteData.url} invite={inviteData.claim ? term : undefined} />
class="card card-interactive"
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
<RelaySummary url={inviteData.url} />
</Button>
{/key} {/key}
{/if} {/if}
{#if otherSpaces.length > 0} {#if otherSpaces.length > 0}
<Divider>Browse Spaces</Divider> <Divider>Browse Spaces</Divider>
{/if} {/if}
{#each otherSpaces.slice(0, limit) as relay (relay.url)} {#each otherSpaces.slice(0, limit) as relay (relay.url)}
<Button class="card card-interactive" onclick={() => openSpace(relay.url)}> <SpaceResult url={relay.url} />
<RelaySummary url={relay.url} />
</Button>
{/each} {/each}
<div class="flex justify-center py-20"> <div class="flex justify-center py-20">
{#await sleep(5000)} {#await sleep(5000)}

View file

@ -1,11 +1,11 @@
<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 {formatTimestamp, sleep, uniq} from "@welshman/lib" import {formatTimestamp, uniq} from "@welshman/lib"
import {Article} from "@welshman/domain" import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" 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 LoadingFallback from "@lib/components/LoadingFallback.svelte"
import MenuButton from "@lib/components/MenuButton.svelte" import MenuButton from "@lib/components/MenuButton.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import ContentMarkdown from "@app/components/ContentMarkdown.svelte" import ContentMarkdown from "@app/components/ContentMarkdown.svelte"
@ -134,12 +134,9 @@
<ArticleAuthorSidebar {url} event={$event} /> <ArticleAuthorSidebar {url} event={$event} />
</div> </div>
{:else} {:else}
<div class="flex grow justify-center py-20"> <LoadingFallback
{#await sleep(5000)} class="grow"
<Spinner loading>Loading article...</Spinner> loadingMessage="Loading article..."
{:then} failureMessage="Failed to load article." />
<p>Failed to load article.</p>
{/await}
</div>
{/if} {/if}
</PageContent> </PageContent>

View file

@ -1,267 +1,18 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation" import ArticleForm from "@app/components/ArticleForm.svelte"
import {now, randomId} from "@welshman/lib"
import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain"
import {isMobile} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import DocumentText from "@assets/icons/document-text.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {publishToSpace} from "@app/rooms"
import {makeArticlePath} from "@app/routes"
import {compressFileForUpload, uploadFile} from "@app/uploads"
import {pushToast} from "@app/toast"
import type {PageProps} from "./$types" import type {PageProps} from "./$types"
type Values = {
d?: string
content?: string | object
title?: string
summary?: string
image?: string
topics?: string[]
}
const {params}: PageProps = $props() const {params}: PageProps = $props()
const url = decodeRelay(params.relay) const url = decodeRelay(params.relay)
const h = $page.url.searchParams.get("h") ?? undefined const h = $page.url.searchParams.get("h") ?? undefined
const shareToChat = $page.url.searchParams.has("shareToChat") const shareToChat = $page.url.searchParams.has("shareToChat")
const draftKey = new DraftKey<Values>(`article:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const coverInputId = randomId()
const uploading = writable(false)
const back = () => goto(makeArticlePath(url))
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const clearCover = () => {
image = ""
}
const onCoverChange = async (event: Event & {currentTarget: HTMLInputElement}) => {
const input = event.currentTarget
const file = input.files?.[0]
// Clear the input so picking the same file again after removing the cover still fires
input.value = ""
if (file) {
uploadingCover = true
try {
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
if (result?.url) {
image = result.url
} else {
pushToast({theme: "error", message: error ?? "Failed to upload your cover image."})
}
} catch (e) {
// compressFileForUpload rejects outside uploadFile's own error handling
console.error("Error caught when uploading cover image:", e)
pushToast({theme: "error", message: "Failed to upload your cover image."})
} finally {
uploadingCover = false
}
}
}
const submit = async () => {
if ($uploading || uploadingCover || loading) {
return
}
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your article.",
})
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({
theme: "error",
message: "Please write something for your article.",
})
}
loading = true
try {
const eventWriter = writer(Article)
.setIdentifier(d)
.setContent(content)
.setTitle(title)
.setTopics(topics)
.setPublishedAt(now())
.addTags(...ed.storage.nostr.getEditorTags())
if (summary) {
eventWriter.setSummary(summary)
}
if (image) {
eventWriter.setImage(image)
}
const {thunk, error} = await publishToSpace(eventWriter, {url, h, shareToChat})
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
goto(makeArticlePath(url, getAddress(thunk.options.event)))
} finally {
loading = false
}
}
let loading = $state(false)
let uploadingCover = $state(false)
let titleInput = $state<HTMLInputElement>()
// The autofocus attribute is ignored while the button that navigated here still holds focus
onMount(() => {
if (!isMobile) {
titleInput?.focus()
}
})
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let summary = $state(initialValues?.summary ?? "")
let image = $state(initialValues?.image ?? "")
let topics = $state(initialValues?.topics ?? [])
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "Write your article...",
content,
})
$effect(() => {
draftKey.update({d, title, summary, image, topics, content})
})
</script> </script>
<SpaceBar {back}> <ArticleForm {url} {h} {shareToChat}>
{#snippet leading()} {#snippet header()}
<Icon icon={DocumentText} />
{/snippet}
{#snippet title()}
<strong>Write an Article</strong> <strong>Write an Article</strong>
{/snippet} {/snippet}
{#snippet action()} </ArticleForm>
<Button
class="button button-primary button-sm"
onclick={submit}
disabled={$uploading || uploadingCover || loading}>
<Spinner {loading}>Publish</Spinner>
</Button>
{/snippet}
</SpaceBar>
<!-- column flex: justify-center would put the top of a tall page out of scroll reach -->
<PageContent class="items-center">
<div class="flex w-full max-w-3xl flex-col gap-6">
<input
id={coverInputId}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onchange={onCoverChange}
class="hidden" />
{#if image}
<div class="relative">
<img src={image} alt="Article cover" class="h-56 w-full rounded-2xl object-cover" />
<Button
data-tip="Remove cover image"
aria-label="Remove cover image"
class="tip tip-left button button-neutral button-sm button-circle absolute right-2 top-2"
onclick={clearCover}>
<Icon icon={CloseCircle} />
</Button>
</div>
{:else}
<label
for={coverInputId}
class="flex h-28 cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed border-line text-content-muted transition-colors hover:text-content">
{#if uploadingCover}
<Spinner loading>Uploading cover image...</Spinner>
{:else}
<Icon icon={GallerySend} />
Add a cover image
{/if}
</label>
{/if}
<div class="flex flex-col gap-2">
<input
bind:this={titleInput}
bind:value={title}
class="bg-transparent text-3xl font-bold outline-none placeholder:text-content-muted"
type="text"
placeholder="Title" />
<input
bind:value={summary}
class="placeholder:text-content-muted bg-transparent text-lg outline-none"
type="text"
placeholder="Add a one line teaser for the article list" />
</div>
<div class="relative">
<div class="article-editor note-editor">
<EditorContent {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>
<Field>
{#snippet label()}
<p>Topics</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
{/snippet}
</Field>
</div>
</PageContent>

View file

@ -5,12 +5,11 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import {derived, readable} from "svelte/store" import {derived, readable} from "svelte/store"
import {sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util" import {getCommentFiltersForRoot} from "@welshman/util"
import {TimeEvent} from "@welshman/domain" import {TimeEvent} from "@welshman/domain"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte" import LoadingFallback from "@lib/components/LoadingFallback.svelte"
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte" import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
import type {SegmentedOption} from "@lib/components/SegmentedButtons.svelte" import type {SegmentedOption} from "@lib/components/SegmentedButtons.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
@ -127,12 +126,6 @@
<CalendarEventPeople {url} event={$event} rsvps={$rsvps} /> <CalendarEventPeople {url} event={$event} rsvps={$rsvps} />
{/if} {/if}
{:else} {:else}
<div class="flex justify-center py-20"> <LoadingFallback loadingMessage="Loading event..." failureMessage="Failed to load event." />
{#await sleep(5000)}
<Spinner loading>Loading event...</Spinner>
{:then}
<p>Failed to load event.</p>
{/await}
</div>
{/if} {/if}
</PageContent> </PageContent>

View file

@ -1,12 +1,12 @@
<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, uniq} from "@welshman/lib" import {uniq} from "@welshman/lib"
import {Classified} from "@welshman/domain" import {Classified} from "@welshman/domain"
import {normalizeTopic} from "@lib/util" import {normalizeTopic} from "@lib/util"
import Link from "@lib/components/Link.svelte" 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 LoadingFallback from "@lib/components/LoadingFallback.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"
@ -72,12 +72,8 @@
</NoteCard> </NoteCard>
<EventComments event={$event} {url} {context} /> <EventComments event={$event} {url} {context} />
{:else} {:else}
<div class="flex justify-center py-20"> <LoadingFallback
{#await sleep(5000)} loadingMessage="Loading listing..."
<Spinner loading>Loading listing...</Spinner> failureMessage="Failed to load classified listing." />
{:then}
<p>Failed to load classified listing.</p>
{/await}
</div>
{/if} {/if}
</PageContent> </PageContent>

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
import {derived, readable} from "svelte/store" import {readable} from "svelte/store"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {equals, now, pluck, sortBy, spec} from "@welshman/lib" import {equals, pluck, spec} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {ZAP_GOAL} from "@welshman/util" import {ZAP_GOAL} from "@welshman/util"
@ -15,6 +15,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte" import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
import type {SegmentedOption} from "@lib/components/SegmentedButtons.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"
@ -23,8 +24,8 @@
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeCommentFilter, partitionByActivity} from "@app/content" import {makeCommentFilter, partitionByActivity} from "@app/content"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {deriveGoalProgress} from "@app/goals" import {deriveGoalProgressById, filterGoals, getGoalTotals, sortGoals} from "@app/goals"
import type {GoalProgress} from "@app/goals" import type {GoalFilter, GoalSort} from "@app/goals"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import type {PageProps} from "./$types" import type {PageProps} from "./$types"
@ -35,7 +36,7 @@
onDestroy(context.cleanup) onDestroy(context.cleanup)
const tabs = [ const tabs: SegmentedOption<GoalFilter>[] = [
{value: "all", label: "All"}, {value: "all", label: "All"},
{value: "live", label: "Live"}, {value: "live", label: "Live"},
{value: "funded", label: "Funded"}, {value: "funded", label: "Funded"},
@ -51,93 +52,26 @@
const createGoal = () => pushModal(GoalCreate, {url}) const createGoal = () => pushModal(GoalCreate, {url})
let tab = $state("all") let tab: GoalFilter = $state("all")
let sort = $state("active") let sort: GoalSort = $state("active")
// progressById resubscribes every goal's zap receipts, so these change only with the goal set. // progressById resubscribes every goal's zap receipts, so goals change only with the goal set.
let goals: TrustedEvent[] = $state([]) let goals: TrustedEvent[] = $state([])
let progressStoresById: Map<string, Readable<GoalProgress>> = $state(new Map())
$effect(() => { $effect(() => {
const next = $events.filter(spec({kind: ZAP_GOAL})) const next = $events.filter(spec({kind: ZAP_GOAL}))
if (!equals(pluck("id", next), pluck("id", goals))) { if (!equals(pluck("id", next), pluck("id", goals))) {
goals = next goals = next
progressStoresById = new Map(
next.map(event => [
event.id,
progressStoresById.get(event.id) ?? deriveGoalProgress(event, url),
]),
)
} }
}) })
// `derived` over no stores never fires, so an empty board needs a store of its own. const progressById = $derived(deriveGoalProgressById(goals, url))
const progressById = $derived.by(() => {
const ids = Array.from(progressStoresById.keys())
const stores = Array.from(progressStoresById.values())
return stores.length > 0
? derived(stores, progresses => new Map(progresses.map((progress, i) => [ids[i]!, progress])))
: readable(new Map<string, GoalProgress>())
})
const {activeAt} = $derived(partitionByActivity(ZAP_GOAL, $events)) const {activeAt} = $derived(partitionByActivity(ZAP_GOAL, $events))
const totals = $derived(getGoalTotals($progressById))
const totals = $derived.by(() => { const items = $derived(
const backers = new Set<string>() sortGoals(sort, filterGoals(tab, goals, $progressById), $progressById, activeAt),
let raised = 0 )
let funded = 0
for (const progress of $progressById.values()) {
raised += progress.raised
funded += progress.isFunded ? 1 : 0
for (const backer of progress.backers) {
backers.add(backer)
}
}
return {raised, funded, backers: backers.size}
})
const items = $derived.by(() => {
const matches = goals.filter(event => {
const progress = $progressById.get(event.id)
if (tab === "live") {
return Boolean(progress && !progress.isEnded && !progress.isFunded)
}
if (tab === "funded") {
return Boolean(progress?.isFunded)
}
if (tab === "ended") {
return Boolean(progress?.isEnded)
}
return true
})
if (sort === "new") {
return sortBy(event => -event.created_at, matches)
}
if (sort === "progress") {
return sortBy(event => -($progressById.get(event.id)?.percent ?? 0), matches)
}
if (sort === "ending") {
return sortBy(event => {
const closedAt = $progressById.get(event.id)?.closedAt
return closedAt && closedAt > now() ? closedAt : Infinity
}, matches)
}
return sortBy(event => -(activeAt.get(event.id) ?? event.created_at), matches)
})
onMount(() => { onMount(() => {
const feed = makeFeed({ const feed = makeFeed({
@ -207,11 +141,7 @@
<div class="columns-1 gap-2 sm:gap-4 lg:columns-2 2xl:columns-3"> <div class="columns-1 gap-2 sm:gap-4 lg:columns-2 2xl:columns-3">
{#each items as event (event.id)} {#each items as event (event.id)}
<div in:fly class="mb-2 min-w-0 break-inside-avoid sm:mb-4"> <div in:fly class="mb-2 min-w-0 break-inside-avoid sm:mb-4">
<GoalItem <GoalItem {url} {context} event={$state.snapshot(event)} />
{url}
{context}
event={$state.snapshot(event)}
progress={progressStoresById.get(event.id)} />
</div> </div>
{/each} {/each}
</div> </div>

View file

@ -1,12 +1,12 @@
<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 {DAY, formatTimestampRelative, now, sleep} from "@welshman/lib" import {DAY, formatTimestampRelative, now} from "@welshman/lib"
import {ZapGoal} from "@welshman/domain" import {ZapGoal} from "@welshman/domain"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte" import LoadingFallback from "@lib/components/LoadingFallback.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 NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
@ -116,12 +116,8 @@
<GoalSupporters {url} event={$event} progress={$progress} /> <GoalSupporters {url} event={$event} progress={$progress} />
<EventComments event={$event} {url} {context} /> <EventComments event={$event} {url} {context} />
{:else} {:else}
<div class="flex justify-center py-20"> <LoadingFallback
{#await sleep(5000)} loadingMessage="Loading funding goal..."
<Spinner loading>Loading funding goal...</Spinner> failureMessage="Failed to load funding goal." />
{:then}
<p>Failed to load funding goal.</p>
{/await}
</div>
{/if} {/if}
</PageContent> </PageContent>

View file

@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
import {sleep} from "@welshman/lib"
import {POLL, POLL_RESPONSE} from "@welshman/util" import {POLL, POLL_RESPONSE} from "@welshman/util"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte" import LoadingFallback from "@lib/components/LoadingFallback.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
@ -59,12 +58,6 @@
</NoteCard> </NoteCard>
<EventComments event={$event} {url} {context} /> <EventComments event={$event} {url} {context} />
{:else} {:else}
<div class="flex justify-center py-20"> <LoadingFallback loadingMessage="Loading poll..." failureMessage="Failed to load poll." />
{#await sleep(5000)}
<Spinner loading>Loading poll...</Spinner>
{:then}
<p>Failed to load poll.</p>
{/await}
</div>
{/if} {/if}
</PageContent> </PageContent>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import {page} from "$app/state" import {page} from "$app/state"
import {sleep, spec} from "@welshman/lib" import {spec} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util" import {getCommentFiltersForRoot} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
@ -9,7 +9,7 @@
import {fade, fly} from "@lib/transition" import {fade, fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte" import LoadingFallback from "@lib/components/LoadingFallback.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ThreadPost from "@app/components/ThreadPost.svelte" import ThreadPost from "@app/components/ThreadPost.svelte"
import ThreadSummaryBar from "@app/components/ThreadSummaryBar.svelte" import ThreadSummaryBar from "@app/components/ThreadSummaryBar.svelte"
@ -216,13 +216,7 @@
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="flex justify-center py-20"> <LoadingFallback loadingMessage="Loading thread..." failureMessage="Failed to load thread." />
{#await sleep(5000)}
<Spinner loading>Loading thread...</Spinner>
{:then}
<p>Failed to load thread.</p>
{/await}
</div>
{/if} {/if}
</PageContent> </PageContent>
{#if $event && scrolledPastRoot} {#if $event && scrolledPastRoot}