diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md index 706b6eba..37a8a2ef 100644 --- a/.agents/skills/flotilla-architecture/SKILL.md +++ b/.agents/skills/flotilla-architecture/SKILL.md @@ -105,8 +105,8 @@ only gate. - `hosting.ts`: client for the hosting backend's HTTP API **Content** -- `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`) and comment/delete - filters +- `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`), comment/delete + filters, and `partitionByActivity`, which the list pages use to sort a kind by latest comment - `feeds.ts`: `makeFeed`, `makeFeedContext`, `makeScrollLoader`, `makeCalendarFeed` - `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts` - `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as @@ -309,7 +309,7 @@ search and the space nav entry. The kind also appears in `CONTENT_NOUNS`, the ki `NIP46_PERMS` leaves out polls, articles and goals, so listing a new kind there is optional. **App module.** `src/app/classifieds.ts` holds the listing logic the page would otherwise inline -(`partitionListings`, `deriveTopicCounts`, `getStatus`, `matchesTopic`, `matchesQuery`). Each is a +(`deriveTopicCounts`, `getStatus`, `matchesTopic`, `matchesQuery`). Each is a small function over a domain reader: ```ts diff --git a/src/app/classifieds.ts b/src/app/classifieds.ts index 15906223..36b1d26a 100644 --- a/src/app/classifieds.ts +++ b/src/app/classifieds.ts @@ -1,6 +1,5 @@ -import {max, partition, pushToMapKey, sortBy, spec} from "@welshman/lib" +import {sortBy} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" -import {CLASSIFIED, tagSpec, tagValue} from "@welshman/util" import {Classified} from "@welshman/domain" import {normalizeTopic} from "@lib/util" import {reader} from "@app/core" @@ -13,28 +12,6 @@ export const CLASSIFIED_STATUS_TABS = [ export const SUGGESTED_TOPICS = ["for sale", "wanted", "job", "service", "free"] -// Splits a feed into listings and their comments, scoring each listing by last activity. -export const partitionListings = (events: TrustedEvent[]) => { - const scores = new Map() - const [listings, comments] = partition(spec({kind: CLASSIFIED}), events) - - for (const comment of comments) { - const id = tagValue(tagSpec("E"), comment.tags) - - if (id) { - pushToMapKey(scores, id, comment.created_at) - } - } - - const activeAt = new Map() - - for (const listing of listings) { - activeAt.set(listing.id, max([...(scores.get(listing.id) || []), listing.created_at])) - } - - return {listings, activeAt} -} - export const deriveTopicCounts = (events: TrustedEvent[]) => { const counts = new Map() diff --git a/src/app/content.ts b/src/app/content.ts index 2f5a7d9d..08472288 100644 --- a/src/app/content.ts +++ b/src/app/content.ts @@ -1,3 +1,4 @@ +import {max, partition, sortBy, spec} from "@welshman/lib" import { CLASSIFIED, COMMENT, @@ -17,11 +18,16 @@ import { ZAP_RECEIPT, matchTags, decryptFile, + getIdOrAddress, + isReplaceableKind, tagSpec, tagValue, } from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util" +import {Comment} from "@welshman/domain" +import {reader} from "@app/core" import {ENABLE_ZAPS} from "@app/env" + export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"] @@ -91,6 +97,27 @@ export const makeDeleteFilter = (kinds: number[], extra: Filter = {}) => ({ ...extra, }) +// An event is active as of its own creation or the newest comment on it, keyed by address when addressable. +export const partitionByActivity = (kind: number, events: TrustedEvent[]) => { + const [items, comments] = partition(spec({kind}), events) + const commentedAt = new Map() + + for (const comment of comments) { + const {id, address} = reader(Comment)(comment).root() + const key = isReplaceableKind(kind) ? address : id + + if (key) { + commentedAt.set(key, max([commentedAt.get(key), comment.created_at])) + } + } + + const activeAt = new Map( + items.map(event => [event.id, max([commentedAt.get(getIdOrAddress(event)), event.created_at])]), + ) + + return {items: sortBy(event => -(activeAt.get(event.id) ?? 0), items), activeAt} +} + export const REPOST_KINDS = [REPOST, GENERIC_REPOST] export const EVENT_CONTEXT_KINDS = [REPORT, REACTION, ZAP_RECEIPT, COMMENT] diff --git a/src/routes/spaces/[relay]/articles/+page.svelte b/src/routes/spaces/[relay]/articles/+page.svelte index b5d7f368..f2469cc7 100644 --- a/src/routes/spaces/[relay]/articles/+page.svelte +++ b/src/routes/spaces/[relay]/articles/+page.svelte @@ -3,10 +3,9 @@ import {readable} from "svelte/store" import type {Readable} from "svelte/store" import {goto} from "$app/navigation" - import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {LONG_FORM, getAddress, tagSpec, tagValue} from "@welshman/util" + import {LONG_FORM, getAddress} from "@welshman/util" import {Article} from "@welshman/domain" import {fly} from "@lib/transition" import DocumentText from "@assets/icons/document-text.svg?dataurl" @@ -20,7 +19,7 @@ import ArticleSidebar from "@app/components/ArticleSidebar.svelte" import {reader} from "@app/core" import {decodeRelay} from "@app/relays" - import {makeCommentFilter} from "@app/content" + import {makeCommentFilter, partitionByActivity} from "@app/content" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {makeArticleCreatePath} from "@app/routes" import type {PageProps} from "./$types" @@ -43,20 +42,7 @@ const createArticle = () => goto(makeArticleCreatePath(url)) - const articles = $derived.by(() => { - const scores = new Map() - const [items, comments] = partition(spec({kind: LONG_FORM}), $events) - - for (const comment of comments) { - const address = tagValue(tagSpec("A"), comment.tags) - - if (address) { - pushToMapKey(scores, address, comment.created_at) - } - } - - return sortBy(e => -max([...(scores.get(getAddress(e)) || []), e.created_at]), items) - }) + const {items: articles} = $derived(partitionByActivity(LONG_FORM, $events)) const filtered = $derived( articles.filter( diff --git a/src/routes/spaces/[relay]/classifieds/+page.svelte b/src/routes/spaces/[relay]/classifieds/+page.svelte index 4ba55f28..d496d025 100644 --- a/src/routes/spaces/[relay]/classifieds/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/+page.svelte @@ -22,12 +22,11 @@ import ClassifiedItem from "@app/components/ClassifiedItem.svelte" import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte" import {decodeRelay} from "@app/relays" - import {makeCommentFilter} from "@app/content" + import {makeCommentFilter, partitionByActivity} from "@app/content" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {makeClassifiedPath} from "@app/routes" import { CLASSIFIED_STATUS_TABS, - partitionListings, deriveTopicCounts, getStatus, matchesTopic, @@ -69,7 +68,7 @@ const topic = $derived($page.url.searchParams.get("topic") ?? "") - const {listings, activeAt} = $derived(partitionListings($events)) + const {items: listings, activeAt} = $derived(partitionByActivity(CLASSIFIED, $events)) const topicCounts = $derived(deriveTopicCounts(listings)) // Narrowed by everything except the status tab, so its counts match what it shows. diff --git a/src/routes/spaces/[relay]/goals/+page.svelte b/src/routes/spaces/[relay]/goals/+page.svelte index ebb20c33..e225c9e8 100644 --- a/src/routes/spaces/[relay]/goals/+page.svelte +++ b/src/routes/spaces/[relay]/goals/+page.svelte @@ -3,10 +3,10 @@ import {derived, readable} from "svelte/store" import type {Readable} from "svelte/store" import cx from "classnames" - import {equals, max, now, pluck, sortBy, spec} from "@welshman/lib" + import {equals, now, pluck, sortBy, spec} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {ZAP_GOAL, tagSpec, tagValue} from "@welshman/util" + import {ZAP_GOAL} from "@welshman/util" import {fly} from "@lib/transition" import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl" import Add from "@assets/icons/add.svg?dataurl" @@ -21,7 +21,7 @@ import GoalItem from "@app/components/GoalItem.svelte" import GoalCreate from "@app/components/GoalCreate.svelte" import {decodeRelay} from "@app/relays" - import {makeCommentFilter} from "@app/content" + import {makeCommentFilter, partitionByActivity} from "@app/content" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {deriveGoalProgress} from "@app/goals" import type {GoalProgress} from "@app/goals" @@ -82,20 +82,7 @@ : readable(new Map()) }) - // A goal is "active" as of its own creation or the newest comment on it. - const activeAt = $derived.by(() => { - const result = new Map() - - for (const event of $events) { - const id = event.kind === ZAP_GOAL ? event.id : tagValue(tagSpec("E"), event.tags) - - if (id) { - result.set(id, max([result.get(id), event.created_at])) - } - } - - return result - }) + const {activeAt} = $derived(partitionByActivity(ZAP_GOAL, $events)) const totals = $derived.by(() => { const backers = new Set() diff --git a/src/routes/spaces/[relay]/polls/+page.svelte b/src/routes/spaces/[relay]/polls/+page.svelte index 521bcca3..080d2d38 100644 --- a/src/routes/spaces/[relay]/polls/+page.svelte +++ b/src/routes/spaces/[relay]/polls/+page.svelte @@ -2,10 +2,9 @@ import {onDestroy, onMount} from "svelte" import {readable} from "svelte/store" import type {Readable} from "svelte/store" - import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {POLL, tagValue, tagSpec} from "@welshman/util" + import {POLL} from "@welshman/util" import {fly} from "@lib/transition" import PollIcon from "@assets/icons/revote.svg?dataurl" import Add from "@assets/icons/add.svg?dataurl" @@ -17,7 +16,7 @@ import PollItem from "@app/components/PollItem.svelte" import PollCreate from "@app/components/PollCreate.svelte" import {decodeRelay} from "@app/relays" - import {makeCommentFilter} from "@app/content" + import {makeCommentFilter, partitionByActivity} from "@app/content" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {pushModal} from "@app/modal" import type {PageProps} from "./$types" @@ -38,20 +37,7 @@ const createPoll = () => pushModal(PollCreate, {url}) - const items = $derived.by(() => { - const scores = new Map() - const [polls, comments] = partition(spec({kind: POLL}), $events) - - for (const comment of comments) { - const id = tagValue(tagSpec("E"), comment.tags) - - if (id) { - pushToMapKey(scores, id, comment.created_at) - } - } - - return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), polls) - }) + const {items} = $derived(partitionByActivity(POLL, $events)) onMount(() => { const feed = makeFeed({ diff --git a/src/routes/spaces/[relay]/threads/+page.svelte b/src/routes/spaces/[relay]/threads/+page.svelte index 1f29986c..740161f4 100644 --- a/src/routes/spaces/[relay]/threads/+page.svelte +++ b/src/routes/spaces/[relay]/threads/+page.svelte @@ -2,10 +2,10 @@ import {onDestroy, onMount} from "svelte" import {readable} from "svelte/store" import type {Readable} from "svelte/store" - import {sortBy, partition, spec, max, pushToMapKey, groupBy} from "@welshman/lib" + import {sortBy, groupBy} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {THREAD, tagValue, tagSpec} from "@welshman/util" + import {THREAD} from "@welshman/util" import {Thread} from "@welshman/domain" import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" import Icon from "@lib/components/Icon.svelte" @@ -16,7 +16,7 @@ import {reader} from "@app/core" import {decodeRelay} from "@app/relays" import {displayRoom} from "@app/rooms" - import {makeCommentFilter} from "@app/content" + import {makeCommentFilter, partitionByActivity} from "@app/content" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import type {PageProps} from "./$types" @@ -35,19 +35,7 @@ let events: Readable = $state(readable([])) const threadFeed = $derived.by(() => { - const scores = new Map() - const [threads, comments] = partition(spec({kind: THREAD}), $events) - - for (const comment of comments) { - const id = tagValue(tagSpec("E"), comment.tags) - - if (id) { - pushToMapKey(scores, id, comment.created_at) - } - } - - const items = sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), threads) - + const {items} = partitionByActivity(THREAD, $events) const grouped = groupBy(e => reader(Thread)(e).room() || "", items) const byRoom = new Map([["", []], ...grouped]) const roomName = (h: string) => (h ? displayRoom(url, h) : "general").toLowerCase()