Sort threads, polls, articles, goals and listings by last activity through one partitionByActivity

This commit is contained in:
Jon Staab 2026-09-25 21:54:13 -07:00
parent b77c8eafd1
commit 0eebfc7dc1
8 changed files with 47 additions and 97 deletions

View file

@ -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

View file

@ -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<string, number[]>()
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<string, number>()
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<string, number>()

View file

@ -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<string, number>()
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]

View file

@ -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<string, number[]>()
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(

View file

@ -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.

View file

@ -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<string, GoalProgress>())
})
// A goal is "active" as of its own creation or the newest comment on it.
const activeAt = $derived.by(() => {
const result = new Map<string, number>()
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<string>()

View file

@ -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<string, number[]>()
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({

View file

@ -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<TrustedEvent[]> = $state(readable([]))
const threadFeed = $derived.by(() => {
const scores = new Map<string, number[]>()
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<string, TrustedEvent[]>([["", []], ...grouped])
const roomName = (h: string) => (h ? displayRoom(url, h) : "general").toLowerCase()