2026-09-23 02:35:06 +00:00
|
|
|
import * as nip19 from "nostr-tools/nip19"
|
|
|
|
|
import {derived, readable} from "svelte/store"
|
|
|
|
|
import {
|
|
|
|
|
first,
|
|
|
|
|
noop,
|
|
|
|
|
pushToMapKey,
|
|
|
|
|
removeUndefined,
|
|
|
|
|
shuffle,
|
|
|
|
|
sortBy,
|
|
|
|
|
tryCatch,
|
|
|
|
|
uniq,
|
|
|
|
|
uniqBy,
|
|
|
|
|
} from "@welshman/lib"
|
|
|
|
|
import type {Maybe} from "@welshman/lib"
|
2026-06-08 22:33:38 +00:00
|
|
|
import {
|
2026-07-28 16:16:26 +00:00
|
|
|
COMMENT,
|
2026-09-23 02:35:06 +00:00
|
|
|
NOTE,
|
2026-07-28 16:16:26 +00:00
|
|
|
addressTags,
|
2026-09-23 02:35:06 +00:00
|
|
|
eventOutbox,
|
|
|
|
|
fromNostrURI,
|
2026-06-08 22:33:38 +00:00
|
|
|
getAddress,
|
2026-09-23 02:35:06 +00:00
|
|
|
getCommentFiltersForRoot,
|
2026-08-17 15:42:14 +00:00
|
|
|
getIdAndAddress,
|
2026-09-23 02:35:06 +00:00
|
|
|
getIdFilters,
|
2026-07-28 16:16:26 +00:00
|
|
|
hexTags,
|
2026-09-23 02:35:06 +00:00
|
|
|
relays as relaySelections,
|
|
|
|
|
seen,
|
2026-07-28 16:16:26 +00:00
|
|
|
tagSpec,
|
|
|
|
|
tagValues,
|
|
|
|
|
topicTags,
|
2026-06-08 22:33:38 +00:00
|
|
|
} from "@welshman/util"
|
2026-09-23 02:35:06 +00:00
|
|
|
import type {EventRef, TrustedEvent} from "@welshman/util"
|
2026-06-08 22:33:38 +00:00
|
|
|
import {withGetter} from "@welshman/store"
|
2026-09-23 02:35:06 +00:00
|
|
|
import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain"
|
|
|
|
|
import {Events, FollowLists, MuteLists, Network, Profiles, Router} from "@welshman/app"
|
|
|
|
|
import type {IApp} from "@welshman/app"
|
|
|
|
|
import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core"
|
2026-06-08 22:33:38 +00:00
|
|
|
import {DEFAULT_PUBKEYS} from "@app/env"
|
|
|
|
|
|
2026-09-23 02:35:06 +00:00
|
|
|
// People
|
|
|
|
|
|
2026-08-31 20:56:42 +00:00
|
|
|
const profileIndex = fromApp($app => $app.use(Profiles).index.$)
|
|
|
|
|
|
|
|
|
|
export const deriveDisplaysByPubkey = (pubkeys: string[], url?: string) => {
|
|
|
|
|
const relays = removeUndefined([url])
|
|
|
|
|
|
|
|
|
|
// Load profiles
|
|
|
|
|
for (const pubkey of pubkeys) {
|
|
|
|
|
if (pubkey) {
|
|
|
|
|
profiles.get().load(pubkey, relays).catch(noop)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return derived(
|
|
|
|
|
profileIndex,
|
|
|
|
|
$index =>
|
|
|
|
|
new Map(
|
|
|
|
|
pubkeys.map(pubkey => [
|
|
|
|
|
pubkey,
|
|
|
|
|
pubkey ? ($index.get(pubkey)?.display() ?? displayPubkey(pubkey)) : "",
|
|
|
|
|
]),
|
|
|
|
|
),
|
2026-08-22 20:12:11 +00:00
|
|
|
new Map<string, string>(),
|
|
|
|
|
)
|
2026-08-31 20:56:42 +00:00
|
|
|
}
|
2026-08-22 20:12:11 +00:00
|
|
|
|
2026-08-14 17:12:18 +00:00
|
|
|
export const bootstrapPubkeys = derived(deriveUserItem(FollowLists), $userFollowList => {
|
|
|
|
|
const appPubkeys = DEFAULT_PUBKEYS.split(",")
|
|
|
|
|
const userPubkeys = shuffle($userFollowList?.pubkeys() ?? [])
|
2026-07-28 16:16:26 +00:00
|
|
|
|
2026-08-14 17:12:18 +00:00
|
|
|
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
|
|
|
|
|
})
|
2026-07-28 16:16:26 +00:00
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Pointers: how a note is named before it has been loaded.
|
2026-09-23 02:35:06 +00:00
|
|
|
|
|
|
|
|
export type NotePointer = EventRef & {id: string}
|
|
|
|
|
|
|
|
|
|
export const decodeNotePointer = (entity: string): Maybe<NotePointer> => {
|
|
|
|
|
const decoded = tryCatch(() => nip19.decode(fromNostrURI(entity)))
|
|
|
|
|
|
|
|
|
|
if (decoded?.type === "nevent") {
|
|
|
|
|
return {id: decoded.data.id, relays: decoded.data.relays, pubkey: decoded.data.author}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (decoded?.type === "note") {
|
|
|
|
|
return {id: decoded.data}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Structure: the shape of a conversation, read off tags alone.
|
2026-09-23 02:35:06 +00:00
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
// Ids and addresses of an event's immediate parents, falling back to its thread roots.
|
2026-09-23 02:35:06 +00:00
|
|
|
export const getParents = ({kind, tags}: TrustedEvent) => {
|
2026-07-28 16:16:26 +00:00
|
|
|
const {roots, replies} = kind === COMMENT ? getCommentTagValues(tags) : getReplyTagValues(tags)
|
|
|
|
|
|
|
|
|
|
return replies.length > 0 ? replies : roots
|
|
|
|
|
}
|
2026-06-08 22:33:38 +00:00
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A reply names the note it answers with an `e` tag, and a deeper one names the thread root too.
|
2026-09-23 02:35:06 +00:00
|
|
|
export const getThreadFilters = (event: TrustedEvent) => {
|
|
|
|
|
const roots = tagValues(hexTags("e"), getReplyTags(event.tags).roots)
|
|
|
|
|
|
|
|
|
|
return [...getCommentFiltersForRoot([event]), {kinds: [NOTE], "#e": uniq([event.id, ...roots])}]
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A reply names only the event above it, so the set grows a generation at a time.
|
2026-09-23 02:35:06 +00:00
|
|
|
export const getDescendants = (root: TrustedEvent, events: TrustedEvent[]) => {
|
|
|
|
|
const values = new Set(getIdAndAddress(root))
|
|
|
|
|
const descendants: TrustedEvent[] = []
|
|
|
|
|
|
|
|
|
|
let growing = true
|
|
|
|
|
|
|
|
|
|
while (growing) {
|
|
|
|
|
growing = false
|
|
|
|
|
|
|
|
|
|
for (const event of events) {
|
|
|
|
|
if (!values.has(event.id) && getParents(event).some(value => values.has(value))) {
|
|
|
|
|
for (const value of getIdAndAddress(event)) {
|
|
|
|
|
values.add(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
descendants.push(event)
|
|
|
|
|
growing = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return descendants
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 15:42:14 +00:00
|
|
|
export type CommentNode = {
|
|
|
|
|
comment: TrustedEvent
|
|
|
|
|
children: CommentNode[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const buildCommentTree = (root: TrustedEvent, comments: TrustedEvent[]) => {
|
|
|
|
|
const byParent = new Map<string, TrustedEvent[]>()
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A comment names its parent by id, and by address where the parent is addressable.
|
2026-08-17 15:42:14 +00:00
|
|
|
for (const comment of comments) {
|
|
|
|
|
for (const parent of getParents(comment)) {
|
|
|
|
|
pushToMapKey(byParent, parent, comment)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Nothing stops a comment naming several parents, which would let the tree cycle.
|
2026-09-23 02:35:06 +00:00
|
|
|
const seenIds = new Set<string>()
|
2026-08-17 15:42:14 +00:00
|
|
|
|
|
|
|
|
const build = (parent: TrustedEvent): CommentNode[] => {
|
|
|
|
|
const children = uniqBy(
|
|
|
|
|
e => e.id,
|
|
|
|
|
getIdAndAddress(parent).flatMap(value => byParent.get(value) ?? []),
|
2026-09-23 02:35:06 +00:00
|
|
|
).filter(e => !seenIds.has(e.id))
|
2026-08-17 15:42:14 +00:00
|
|
|
|
|
|
|
|
for (const child of children) {
|
2026-09-23 02:35:06 +00:00
|
|
|
seenIds.add(child.id)
|
2026-08-17 15:42:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return children.map(comment => ({comment, children: build(comment)}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nodes = build(root)
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A comment can name a parent we don't have, and `comments` is oldest first, so parents come before children.
|
2026-08-17 15:42:14 +00:00
|
|
|
for (const comment of comments) {
|
2026-09-23 02:35:06 +00:00
|
|
|
if (!seenIds.has(comment.id)) {
|
|
|
|
|
seenIds.add(comment.id)
|
2026-08-17 15:42:14 +00:00
|
|
|
nodes.push({comment, children: build(comment)})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sortBy(node => node.comment.created_at, nodes)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 02:35:06 +00:00
|
|
|
// Muting
|
|
|
|
|
|
2026-06-08 22:33:38 +00:00
|
|
|
export const isEventMuted = withGetter(
|
2026-08-14 17:12:18 +00:00
|
|
|
derived([user, deriveUserItem(MuteLists)], ([$user, $muteList]) => {
|
|
|
|
|
const tags = $muteList?.tags() ?? []
|
2026-07-28 16:16:26 +00:00
|
|
|
const mutedEvents = new Set(tagValues(hexTags("e"), tags))
|
|
|
|
|
const mutedPubkeys = new Set(tagValues(hexTags("p"), tags))
|
|
|
|
|
const mutedAddresses = new Set(tagValues(addressTags("a"), tags))
|
|
|
|
|
const mutedTopics = new Set(tagValues(topicTags("t"), tags))
|
|
|
|
|
const mutedWords = tagValues(tagSpec("word"), tags)
|
2026-06-08 22:33:38 +00:00
|
|
|
const regex =
|
|
|
|
|
mutedWords.length > 0
|
|
|
|
|
? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`)
|
2026-07-15 16:30:17 +00:00
|
|
|
: undefined
|
2026-06-08 22:33:38 +00:00
|
|
|
|
|
|
|
|
return (e: TrustedEvent) => {
|
2026-09-21 16:48:35 +00:00
|
|
|
if (!$muteList) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if ($user.pubkey === e.pubkey) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if (mutedPubkeys.has(e.pubkey)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if (mutedEvents.has(e.id)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if (mutedAddresses.has(getAddress(e))) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if (getParents(e).some(v => mutedEvents.has(v) || mutedAddresses.has(v))) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if (tagValues(topicTags("t"), e.tags).some(t => mutedTopics.has(t))) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-06-08 22:33:38 +00:00
|
|
|
|
|
|
|
|
if (regex) {
|
2026-07-28 16:16:26 +00:00
|
|
|
const profile = profiles.get().get(e.pubkey)
|
|
|
|
|
|
2026-09-21 16:48:35 +00:00
|
|
|
if (profile?.display().toLowerCase().match(regex)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if (profile?.nip05()?.match(regex)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-06-08 22:33:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-09-23 02:35:06 +00:00
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Loading: where a note lives, and the stores a page reads it and its conversation from.
|
2026-09-23 02:35:06 +00:00
|
|
|
|
|
|
|
|
export class Notes {
|
|
|
|
|
constructor(private readonly app: IApp) {}
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The hints it arrived with, the relays it has been seen on, and its author's outbox.
|
2026-09-23 02:35:06 +00:00
|
|
|
relays = (ref: EventRef) =>
|
|
|
|
|
this.app
|
|
|
|
|
.use(Router)
|
|
|
|
|
.resolver.relays([...relaySelections(ref.relays ?? []), seen(ref), eventOutbox(ref)])
|
|
|
|
|
|
|
|
|
|
load = async (pointer: NotePointer) =>
|
|
|
|
|
this.app.repository.getEvent(pointer.id) ??
|
|
|
|
|
first(
|
|
|
|
|
await this.app
|
|
|
|
|
.use(Network)
|
|
|
|
|
.loadComplete({relays: await this.relays(pointer), filters: getIdFilters([pointer.id])}),
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A pointer carrying no hints would never resolve, so the load beside it asks the author's relays.
|
2026-09-23 02:35:06 +00:00
|
|
|
deriveEvent = (pointer: NotePointer) => {
|
|
|
|
|
this.load(pointer)
|
|
|
|
|
|
|
|
|
|
return this.app.use(Events).one(pointer.id, pointer.relays ?? []).$
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The filters reach past direct replies, so the tree is built from what hangs off the note.
|
2026-09-23 02:35:06 +00:00
|
|
|
deriveReplies = (event: TrustedEvent) =>
|
|
|
|
|
readable<CommentNode[]>([], set => {
|
|
|
|
|
const filters = getThreadFilters(event)
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
|
|
|
|
this.relays(event).then(relays =>
|
|
|
|
|
this.app.use(Network).request({relays, filters, signal: controller.signal}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const unsubscribe = this.app
|
|
|
|
|
.use(Events)
|
|
|
|
|
.asc(filters)
|
|
|
|
|
.$.subscribe($events => set(buildCommentTree(event, getDescendants(event, $events))))
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
controller.abort()
|
|
|
|
|
unsubscribe()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const notes = usePlugin(Notes)
|