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}
reactionClass="tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
<EventActivity {path} {event} {context} />
{/if}
<EventActions {url} {event} noun="Article" />
</ThunkStatusOrDeleted>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,25 +1,28 @@
<script lang="ts">
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 {getCommentFiltersForRoot, tagSpec, tagValue} from "@welshman/util"
import {COMMENT, tagSpec, tagValue} from "@welshman/util"
import {fade} from "@lib/transition"
import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.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 {makeThreadPath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
context: FeedContext
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 lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
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 REACTION_DISPLAY_KINDS = [REPORT, REACTION, ZAP_RECEIPT]
export const EVENT_CONTEXT_KINDS = [REPORT, REACTION, ZAP_RECEIPT, COMMENT]
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 {batch, between, call, int, now, on, sortBy, uniqBy, MONTH, YEAR} from "@welshman/lib"
import {
COMMENT,
DELETE,
EVENT_TIME,
addressTags,
getAddress,
getCommentFiltersForRoot,
getReplyFilters,
hexTags,
isReplaceableKind,
matchFilters,
tagSpec,
tagValue,
@ -17,12 +21,24 @@ import {mergeRepositoryUpdates} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net"
import {createScroller} from "@lib/html"
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 {getEventsForUrl} from "@app/repository"
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[]>}) => {
const {repository} = app.get()
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
// later can pick it up out of the repository
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) {
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
// through the update listener, so file it before asking the network for the rest
for (const event of repository.query(
getReplyFilters(events, {kinds: REACTION_DISPLAY_KINDS}),
)) {
for (const event of repository.query([
...getReplyFilters(events, {kinds: EVENT_CONTEXT_KINDS}),
...getCommentFiltersForRoot(events),
])) {
addEvent(event, touched)
}
@ -94,7 +111,10 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
const context = await network.get().load({
relays: urls,
signal: controller.signal,
filters: getReplyFilters(events, {kinds: REACTION_KINDS}),
filters: [
...getReplyFilters(events, {kinds: REACTION_KINDS}),
...getCommentFiltersForRoot(events),
],
})
if (context.length > 0) {
@ -114,7 +134,7 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
const touched = new Set<string>()
for (const event of added) {
if (REACTION_DISPLAY_KINDS.includes(event.kind)) {
if (EVENT_CONTEXT_KINDS.includes(event.kind)) {
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
const add = (event: TrustedEvent) => {
if (!targets.has(event.id)) {
targets.add(event.id)
for (const key of getKeys(event)) {
targets.add(key)
}
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 {
add,
related: (event: TrustedEvent): Readable<TrustedEvent[]> => {
add(event)
return readable(eventsByTarget.get(event.id) || noEvents, set => {
let subscribers = subscribersByTarget.get(event.id)
const [key, ...rest] = getKeys(event)
if (!subscribers) {
subscribers = new Set()
subscribersByTarget.set(event.id, subscribers)
}
subscribers.add(set)
set(eventsByTarget.get(event.id) || noEvents)
return () => {
subscribers.delete(set)
if (subscribers.size === 0) {
subscribersByTarget.delete(event.id)
}
}
})
// A replaceable event collects both its buckets, and something tagging it by id and
// address at once lands in both
return rest.length > 0
? derived([relatedForKey(key), ...rest.map(relatedForKey)], buckets =>
uniqBy(event => event.id, buckets.flat()),
)
: relatedForKey(key)
},
cleanup: () => {
controller.abort()

View file

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