Sort threads, polls, articles, goals and listings by last activity through one partitionByActivity
This commit is contained in:
parent
b77c8eafd1
commit
0eebfc7dc1
8 changed files with 47 additions and 97 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue