diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md index 627d9314..a6bb7b63 100644 --- a/.agents/skills/flotilla-architecture/SKILL.md +++ b/.agents/skills/flotilla-architecture/SKILL.md @@ -331,7 +331,8 @@ with `makeFeed` and filters them with the `classifieds.ts` helpers. `[address]/+ one listing with `$events.one(address, [url]).$`. 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 diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md index 72c94387..19bc34d6 100644 --- a/.agents/skills/flotilla-state/SKILL.md +++ b/.agents/skills/flotilla-state/SKILL.md @@ -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 | | `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`) | +| `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 | Each is exposed with `usePlugin`. `Statuses` is the minimal shape: diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md index 44262526..03ce225c 100644 --- a/.agents/skills/flotilla-views/SKILL.md +++ b/.agents/skills/flotilla-views/SKILL.md @@ -229,8 +229,8 @@ lives there: `ModalSubtitle`, `ModalFooter`, `Confirm` - controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`, `DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons` -- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`, `Cv`, - `VirtualList`, `Masonry`, `DragList`, `ScrollToTop` +- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `LoadingFallback`, `Tooltip`, + `Tippy`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop` - 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 @@ -324,14 +324,13 @@ data every page needs lives in `src/app/sync.ts`. 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 -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 -{#await sleep(5000)} - Loading listing... -{:then} -

Failed to load classified listing.

-{/await} + ``` **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` 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` - 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`. diff --git a/src/app/components/ArticleForm.svelte b/src/app/components/ArticleForm.svelte new file mode 100644 index 00000000..b986bdba --- /dev/null +++ b/src/app/components/ArticleForm.svelte @@ -0,0 +1,270 @@ + + + + + + {#snippet leading()} + + {/snippet} + {#snippet title()} + {@render header()} + {/snippet} + {#snippet action()} + + {/snippet} + + + + +
+ + {#if image} +
+ Article cover + +
+ {:else} + + {/if} +
+ + +
+
+
+ +
+ +
+ + {#snippet label()} +

Topics

+ {/snippet} + {#snippet input()} + + {/snippet} +
+
+
diff --git a/src/app/components/GoalItem.svelte b/src/app/components/GoalItem.svelte index 22ec59ec..18a259bb 100644 --- a/src/app/components/GoalItem.svelte +++ b/src/app/components/GoalItem.svelte @@ -1,5 +1,4 @@ diff --git a/src/app/components/NoteDetail.svelte b/src/app/components/NoteDetail.svelte index e4646768..c78a9554 100644 --- a/src/app/components/NoteDetail.svelte +++ b/src/app/components/NoteDetail.svelte @@ -1,14 +1,13 @@ + + diff --git a/src/app/goals.ts b/src/app/goals.ts index 4b1641e5..413cd467 100644 --- a/src/app/goals.ts +++ b/src/app/goals.ts @@ -1,12 +1,13 @@ -import {derived} from "svelte/store" +import {derived, 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 type {TrustedEvent} from "@welshman/util" import {ZapGoal} from "@welshman/domain" import type {Zap} from "@welshman/domain" -import {ZapGoals} from "@welshman/app" -import {app, reader} from "@app/core" +import {Domain, ZapGoals} from "@welshman/app" +import type {IApp} from "@welshman/app" +import {app} from "@app/core" export type GoalProgress = { zaps: Zap[] @@ -19,22 +20,118 @@ export type GoalProgress = { closedAt?: number } -// Welshman tallies a goal in millisats, and comparing there avoids funding a rounded sat early. -export const deriveGoalProgress = (event: TrustedEvent, url?: string): Readable => { - const closedAt = reader(ZapGoal)(event).closedAt() - const progress = app - .get() - .use(ZapGoals) - .progress(event, removeUndefined([url])).$ +export type GoalFilter = "all" | "live" | "funded" | "ended" - 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 type GoalSort = "active" | "new" | "progress" | "ending" + +export class GoalProgresses { + private progressByKey = new Map>() + + constructor(private readonly app: IApp) {} + + forGoal = (event: TrustedEvent, url?: string) => { + 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 => { + 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()) + +export const getGoalTotals = (progressById: Map) => { + const backers = new Set() + 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, +) => + 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, + activeAt: Map, +) => { + 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) } diff --git a/src/app/rooms.ts b/src/app/rooms.ts index dcb25d3c..a3ba7274 100644 --- a/src/app/rooms.ts +++ b/src/app/rooms.ts @@ -1,6 +1,6 @@ import * as nip19 from "nostr-tools/nip19" 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 { ROOM_DELETE_EVENT, @@ -15,7 +15,8 @@ import { } from "@welshman/util" import type {EventContent, TrustedEvent} from "@welshman/util" 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 type {Room, RoomMeta} from "@welshman/app" import { @@ -266,6 +267,45 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) => ([$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>() + + 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 = () => derived( [userSpaceUrls, userRoomList, rooms.get().byUrl.$], diff --git a/src/lib/components/LoadingFallback.svelte b/src/lib/components/LoadingFallback.svelte new file mode 100644 index 00000000..2b64dab1 --- /dev/null +++ b/src/lib/components/LoadingFallback.svelte @@ -0,0 +1,21 @@ + + +
+ {#await sleep(5000)} + {loadingMessage} + {:then} +

{failureMessage}

+ {/await} +
diff --git a/src/routes/spaces/+page.svelte b/src/routes/spaces/+page.svelte index 0b280c01..46e6a1e0 100644 --- a/src/routes/spaces/+page.svelte +++ b/src/routes/spaces/+page.svelte @@ -1,11 +1,8 @@ - - {#snippet leading()} - - {/snippet} - {#snippet title()} + + {#snippet header()} Write an Article {/snippet} - {#snippet action()} - - {/snippet} - - - - -
- - {#if image} -
- Article cover - -
- {:else} - - {/if} -
- - -
-
-
- -
- -
- - {#snippet label()} -

Topics

- {/snippet} - {#snippet input()} - - {/snippet} -
-
-
+ diff --git a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte index 721b9cbc..85df18b9 100644 --- a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte @@ -5,12 +5,11 @@