Thread context through to EventActivity

This commit is contained in:
Jon Staab 2026-08-29 09:48:04 -07:00
parent 0dab2cc779
commit ee09c975f8
13 changed files with 97 additions and 61 deletions

View file

@ -56,7 +56,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {path} {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Article" /> <EventActions {url} {event} noun="Article" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -52,7 +52,7 @@
{createReaction} {createReaction}
reactionClass="tip tip-left" /> reactionClass="tip tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {path} {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Event"> <EventActions {url} {event} noun="Event">
{#snippet customActions()} {#snippet customActions()}

View file

@ -70,7 +70,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {path} {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Listing"> <EventActions {url} {event} noun="Listing">
{#snippet customActions()} {#snippet customActions()}

View file

@ -39,7 +39,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity && path} {#if showActivity && path}
<EventActivity {url} {path} {event} /> <EventActivity {path} {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Comment" /> <EventActions {url} {event} noun="Comment" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -1,24 +1,19 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {derived} from "svelte/store"
import {max, gt, formatTimestampRelative} from "@welshman/lib" import {filter, max, gt, spec, formatTimestampRelative} from "@welshman/lib"
import {getCommentFiltersForRoot} from "@welshman/util" import {COMMENT} from "@welshman/util"
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 {deriveEvents} from "@app/repository" import type {FeedContext} from "@app/feeds"
import {network} from "@app/core"
import {deriveChecked} from "@app/notifications" import {deriveChecked} from "@app/notifications"
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props() const {path, event, context}: {path: string; event: TrustedEvent; context: FeedContext} = $props()
const checked = deriveChecked(path) const checked = deriveChecked(path)
const filters = getCommentFiltersForRoot([event]) const related = context.related(event)
const replies = deriveEvents(filters) const replies = derived(related, $related => filter(spec({kind: COMMENT}), $related))
const lastActive = $derived(max([...$replies, event].map(e => e.created_at))) const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
onMount(() => {
$network.load({relays: [url], filters})
})
</script> </script>
<div class="flex-inline button button-neutral button-xs gap-1 rounded-full"> <div class="flex-inline button button-neutral button-xs gap-1 rounded-full">

View file

@ -47,7 +47,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {path} {event} {context} />
{/if} {/if}
<EventActions {url} {event} hideZap noun="Goal" /> <EventActions {url} {event} hideZap noun="Goal" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -44,7 +44,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {path} {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Poll" /> <EventActions {url} {event} noun="Poll" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -43,7 +43,7 @@
{createReaction} {createReaction}
reactionClass="tip-left" /> reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {path} {event} {context} />
{/if} {/if}
<EventActions {url} {event} noun="Thread" /> <EventActions {url} {event} noun="Thread" />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>

View file

@ -3,14 +3,16 @@
import {matchMd} from "@lib/theme" import {matchMd} from "@lib/theme"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte" import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte"
import type {FeedContext} from "@app/feeds"
type Props = { type Props = {
url: string url: string
h: string h: string
threads: TrustedEvent[] threads: TrustedEvent[]
context: FeedContext
} }
const {url, h, threads}: Props = $props() const {url, h, threads, context}: Props = $props()
</script> </script>
<section class="card card-flat p-0"> <section class="card card-flat p-0">
@ -42,7 +44,7 @@
</thead> </thead>
<tbody> <tbody>
{#each threads as event (event.id)} {#each threads as event (event.id)}
<ThreadBoardItem {url} {event} /> <ThreadBoardItem {url} {event} {context} />
{/each} {/each}
</tbody> </tbody>
</table> </table>
@ -50,7 +52,7 @@
{:else} {:else}
<div class="pb-4"> <div class="pb-4">
{#each threads as event (event.id)} {#each threads as event (event.id)}
<ThreadBoardItem mobile {url} {event} /> <ThreadBoardItem mobile {url} {event} {context} />
{/each} {/each}
</div> </div>
{/if} {/if}

View file

@ -1,25 +1,28 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {formatTimestamp, max} from "@welshman/lib" import {derived} from "svelte/store"
import {filter, formatTimestamp, max, spec} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getCommentFiltersForRoot, tagSpec, tagValue} from "@welshman/util" import {COMMENT, tagSpec, tagValue} from "@welshman/util"
import {fade} from "@lib/transition" import {fade} from "@lib/transition"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import {deriveEventsForUrl} from "@app/repository" import type {FeedContext} from "@app/feeds"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
import {makeThreadPath} from "@app/routes" import {makeThreadPath} from "@app/routes"
type Props = { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
context: FeedContext
mobile?: boolean mobile?: boolean
} }
const {url, event, mobile = false}: Props = $props() const {url, event, context, mobile = false}: Props = $props()
const replies = deriveEventsForUrl(url, getCommentFiltersForRoot([event])) const related = context.related(event)
const replies = derived(related, $related => filter(spec({kind: COMMENT}), $related))
const replyCount = $derived($replies.length) const replyCount = $derived($replies.length)
const lastActive = $derived(max([...$replies, event].map(e => e.created_at))) const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
const title = tagValue(tagSpec("title"), event.tags) const title = tagValue(tagSpec("title"), event.tags)

View file

@ -36,7 +36,7 @@ export const makeDeleteFilter = (kinds: number[], extra: Filter = {}) => ({
export const REPOST_KINDS = [REPOST, GENERIC_REPOST] export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
export const REACTION_DISPLAY_KINDS = [REPORT, REACTION, ZAP_RECEIPT] export const EVENT_CONTEXT_KINDS = [REPORT, REACTION, ZAP_RECEIPT, COMMENT]
export const REACTION_KINDS = [REPORT, DELETE, REACTION] export const REACTION_KINDS = [REPORT, DELETE, REACTION]

View file

@ -1,12 +1,16 @@
import {readable, writable} from "svelte/store" import {derived, readable, writable} from "svelte/store"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {batch, between, call, int, now, on, sortBy, uniqBy, MONTH, YEAR} from "@welshman/lib" import {batch, between, call, int, now, on, sortBy, uniqBy, MONTH, YEAR} from "@welshman/lib"
import { import {
COMMENT,
DELETE, DELETE,
EVENT_TIME, EVENT_TIME,
addressTags,
getAddress, getAddress,
getCommentFiltersForRoot,
getReplyFilters, getReplyFilters,
hexTags, hexTags,
isReplaceableKind,
matchFilters, matchFilters,
tagSpec, tagSpec,
tagValue, tagValue,
@ -17,12 +21,24 @@ import {mergeRepositoryUpdates} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
import {REACTION_DISPLAY_KINDS, REACTION_KINDS} from "@app/content" import {EVENT_CONTEXT_KINDS, REACTION_KINDS} from "@app/content"
import {app, network} from "@app/core" import {app, network} from "@app/core"
import {getEventsForUrl} from "@app/repository" import {getEventsForUrl} from "@app/repository"
const noEvents: TrustedEvent[] = [] const noEvents: TrustedEvent[] = []
// Reactions, zaps and reports point at their subject with `e`/`a`. A NIP-22 comment instead
// points at its thread *root* with `E`/`A`, so filing it by those tags puts a whole thread in
// the root's bucket — which is the scope a reply count wants.
const getTargets = ({kind, tags}: TrustedEvent) =>
kind === COMMENT
? [...tagValues(hexTags("E"), tags), ...tagValues(addressTags("A"), tags)]
: [...tagValues(hexTags("e"), tags), ...tagValues(addressTags("a"), tags)]
// A related event may point at a replaceable one by either id or address, so both are keys
const getKeys = (event: TrustedEvent) =>
isReplaceableKind(event.kind) ? [event.id, getAddress(event)] : [event.id]
export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}) => { export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}) => {
const {repository} = app.get() const {repository} = app.get()
const controller = new AbortController() const controller = new AbortController()
@ -35,7 +51,7 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
// An event seen before its target was tracked stays unfiled, so that adding the target // An event seen before its target was tracked stays unfiled, so that adding the target
// later can pick it up out of the repository // later can pick it up out of the repository
if (!targetsByEventId.has(event.id)) { if (!targetsByEventId.has(event.id)) {
const eventTargets = tagValues(hexTags("e"), event.tags).filter(target => targets.has(target)) const eventTargets = getTargets(event).filter(target => targets.has(target))
if (eventTargets.length > 0) { if (eventTargets.length > 0) {
targetsByEventId.set(event.id, eventTargets) targetsByEventId.set(event.id, eventTargets)
@ -81,9 +97,10 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
// What's already local — an earlier page, our own optimistic reactions — never comes // What's already local — an earlier page, our own optimistic reactions — never comes
// through the update listener, so file it before asking the network for the rest // through the update listener, so file it before asking the network for the rest
for (const event of repository.query( for (const event of repository.query([
getReplyFilters(events, {kinds: REACTION_DISPLAY_KINDS}), ...getReplyFilters(events, {kinds: EVENT_CONTEXT_KINDS}),
)) { ...getCommentFiltersForRoot(events),
])) {
addEvent(event, touched) addEvent(event, touched)
} }
@ -94,7 +111,10 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
const context = await network.get().load({ const context = await network.get().load({
relays: urls, relays: urls,
signal: controller.signal, signal: controller.signal,
filters: getReplyFilters(events, {kinds: REACTION_KINDS}), filters: [
...getReplyFilters(events, {kinds: REACTION_KINDS}),
...getCommentFiltersForRoot(events),
],
}) })
if (context.length > 0) { if (context.length > 0) {
@ -114,7 +134,7 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
const touched = new Set<string>() const touched = new Set<string>()
for (const event of added) { for (const event of added) {
if (REACTION_DISPLAY_KINDS.includes(event.kind)) { if (EVENT_CONTEXT_KINDS.includes(event.kind)) {
addEvent(event, touched) addEvent(event, touched)
} }
} }
@ -130,35 +150,49 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
// Track an event so its context gets requested with the rest of the batch // Track an event so its context gets requested with the rest of the batch
const add = (event: TrustedEvent) => { const add = (event: TrustedEvent) => {
if (!targets.has(event.id)) { if (!targets.has(event.id)) {
targets.add(event.id) for (const key of getKeys(event)) {
targets.add(key)
}
loadContext(event) loadContext(event)
} }
} }
const relatedForKey = (key: string) =>
readable(eventsByTarget.get(key) || noEvents, set => {
let subscribers = subscribersByTarget.get(key)
if (!subscribers) {
subscribers = new Set()
subscribersByTarget.set(key, subscribers)
}
subscribers.add(set)
set(eventsByTarget.get(key) || noEvents)
return () => {
subscribers.delete(set)
if (subscribers.size === 0) {
subscribersByTarget.delete(key)
}
}
})
return { return {
add, add,
related: (event: TrustedEvent): Readable<TrustedEvent[]> => { related: (event: TrustedEvent): Readable<TrustedEvent[]> => {
add(event) add(event)
return readable(eventsByTarget.get(event.id) || noEvents, set => { const [key, ...rest] = getKeys(event)
let subscribers = subscribersByTarget.get(event.id)
if (!subscribers) { // A replaceable event collects both its buckets, and something tagging it by id and
subscribers = new Set() // address at once lands in both
subscribersByTarget.set(event.id, subscribers) return rest.length > 0
} ? derived([relatedForKey(key), ...rest.map(relatedForKey)], buckets =>
uniqBy(event => event.id, buckets.flat()),
subscribers.add(set) )
set(eventsByTarget.get(event.id) || noEvents) : relatedForKey(key)
return () => {
subscribers.delete(set)
if (subscribers.size === 0) {
subscribersByTarget.delete(event.id)
}
}
})
}, },
cleanup: () => { cleanup: () => {
controller.abort() controller.abort()

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onDestroy, onMount} from "svelte"
import {readable} from "svelte/store" import {readable} from "svelte/store"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {page} from "$app/stores" import {page} from "$app/stores"
@ -18,10 +18,13 @@
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {displayRoom} from "@app/rooms" import {displayRoom} from "@app/rooms"
import {makeCommentFilter} from "@app/content" import {makeCommentFilter} from "@app/content"
import {makeFeed} from "@app/feeds" import {makeFeed, makeFeedContext} from "@app/feeds"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const context = makeFeedContext({relays: [url]})
onDestroy(context.cleanup)
let loading = $state(true) let loading = $state(true)
let element: HTMLElement | undefined = $state() let element: HTMLElement | undefined = $state()
@ -54,6 +57,7 @@
const feed = makeFeed({ const feed = makeFeed({
relays: [url], relays: [url],
element: element!, element: element!,
onEvent: context.add,
filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])], filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])],
onBackwardExhausted: () => { onBackwardExhausted: () => {
loading = false loading = false
@ -62,9 +66,7 @@
events = feed.events events = feed.events
return () => { return () => feed.cleanup()
feed.cleanup()
}
}) })
</script> </script>
@ -85,7 +87,7 @@
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4"> <PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#each threadFeed.boards as [h, threads] (h || "general")} {#each threadFeed.boards as [h, threads] (h || "general")}
<ThreadBoard {url} {h} {threads} /> <ThreadBoard {url} {h} {threads} {context} />
{/each} {/each}
<p class="flex h-10 items-center justify-center py-20"> <p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}> <Spinner {loading}>