2026-08-18 16:21:22 +00:00
|
|
|
import { neventEncode, decode } from 'nostr-tools/nip19'
|
|
|
|
|
import {
|
|
|
|
|
spec,
|
|
|
|
|
sortBy,
|
|
|
|
|
groupBy,
|
|
|
|
|
displayList,
|
|
|
|
|
secondsToDate,
|
|
|
|
|
} from '@welshman/lib'
|
|
|
|
|
import { parse, truncate, renderAsHtml } from '@welshman/content'
|
|
|
|
|
import {
|
|
|
|
|
TrustedEvent,
|
2026-08-26 21:57:26 +00:00
|
|
|
normalizeRelayUrl,
|
2026-08-18 16:21:22 +00:00
|
|
|
getParentId,
|
|
|
|
|
NOTE,
|
|
|
|
|
COMMENT,
|
|
|
|
|
REACTION,
|
|
|
|
|
displayProfile,
|
|
|
|
|
displayPubkey,
|
2026-08-26 21:57:26 +00:00
|
|
|
getTagValue,
|
2026-08-18 16:21:22 +00:00
|
|
|
} from '@welshman/util'
|
|
|
|
|
import { displayDuration, createElement } from './util.js'
|
|
|
|
|
import type { Subscription } from './alert.js'
|
|
|
|
|
import { sendDigest } from './mailer.js'
|
2026-08-26 21:57:26 +00:00
|
|
|
import { EVENT_VIEWER_URL } from './env.js'
|
2026-08-18 16:21:22 +00:00
|
|
|
import {
|
|
|
|
|
profilesByPubkey,
|
|
|
|
|
loadProfile,
|
2026-09-18 14:46:27 +00:00
|
|
|
repository,
|
2026-08-18 16:21:22 +00:00
|
|
|
} from './repository.js'
|
|
|
|
|
|
|
|
|
|
type DigestData = {
|
|
|
|
|
events: TrustedEvent[]
|
|
|
|
|
context: TrustedEvent[]
|
2026-08-27 18:49:56 +00:00
|
|
|
relayByEventId: Map<string, string>
|
2026-08-18 16:21:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class Digest {
|
|
|
|
|
since: number
|
|
|
|
|
|
|
|
|
|
constructor(readonly sub: Subscription) {
|
|
|
|
|
this.since = sub.last_digest_at || sub.created_at
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadHandler = async () => {
|
|
|
|
|
// Default handler for building event links
|
2026-08-26 21:57:26 +00:00
|
|
|
return `${EVENT_VIEWER_URL}/`
|
2026-08-18 16:21:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
buildParameters = async (data: DigestData) => {
|
|
|
|
|
const getEventVariables = (event: TrustedEvent) => {
|
|
|
|
|
const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 })
|
|
|
|
|
|
|
|
|
|
return {
|
2026-08-27 18:49:56 +00:00
|
|
|
Link: buildLink(event, handler, data.relayByEventId.get(event.id)),
|
2026-08-18 16:21:22 +00:00
|
|
|
Timestamp: formatter.format(secondsToDate(event.created_at)),
|
|
|
|
|
Icon: profilesByPubkey.get().get(event.pubkey)?.picture,
|
|
|
|
|
Name: displayProfileByPubkey(event.pubkey),
|
|
|
|
|
Content: renderAsHtml(parsed, { createElement, renderEntity }).toString(),
|
|
|
|
|
Replies:
|
|
|
|
|
repliesByParentId.get(event.id)?.filter((e) => [COMMENT, NOTE].includes(e.kind))
|
|
|
|
|
?.length || 0,
|
|
|
|
|
Reactions: repliesByParentId.get(event.id)?.filter(spec({ kind: REACTION }))?.length || 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { events, context } = data
|
|
|
|
|
const formatter = getFormatter()
|
|
|
|
|
const handler = await this.loadHandler()
|
|
|
|
|
const repliesByParentId = groupBy(getParentId, context)
|
|
|
|
|
const eventsByPubkey = groupBy((e) => e.pubkey, events)
|
2026-08-26 21:57:26 +00:00
|
|
|
const userProfile = profilesByPubkey.get().get(this.sub.pubkey)
|
|
|
|
|
const sorted = sortBy((e) => e.created_at, events).slice(0, 100)
|
2026-08-18 16:21:22 +00:00
|
|
|
const topProfiles = sortBy(
|
|
|
|
|
([k, ev]) => -ev.length,
|
|
|
|
|
Array.from(eventsByPubkey.entries()).filter(([k]) => profilesByPubkey.get().get(k))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
Total: events.length,
|
|
|
|
|
Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since),
|
2026-09-14 17:00:07 +00:00
|
|
|
Latest: sorted.map((e) => getEventVariables(e)),
|
|
|
|
|
HasLatest: sorted.length > 0,
|
2026-08-26 21:57:26 +00:00
|
|
|
UserName: displayProfile(userProfile, this.sub.email.split('@')[0]),
|
2026-08-18 16:21:22 +00:00
|
|
|
TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => {
|
|
|
|
|
const events = storedEvents.map(se => se.event)
|
2026-08-27 18:49:56 +00:00
|
|
|
const relayByEventId = new Map(storedEvents.map(se => [se.event.id, se.relay]))
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
// Load profiles for event authors
|
|
|
|
|
const pubkeys = new Set(events.map(e => e.pubkey))
|
2026-08-26 21:57:26 +00:00
|
|
|
pubkeys.add(this.sub.pubkey)
|
2026-08-18 16:21:22 +00:00
|
|
|
for (const pk of pubkeys) {
|
|
|
|
|
try {
|
|
|
|
|
await loadProfile(pk)
|
2026-08-27 19:18:52 +00:00
|
|
|
await waitForProfile(pk)
|
2026-08-18 16:21:22 +00:00
|
|
|
} catch {
|
|
|
|
|
// pass
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 14:46:27 +00:00
|
|
|
// Load reply/reaction context: events that tag our matched events
|
|
|
|
|
const eventIds = events.map(e => e.id)
|
|
|
|
|
const replyEvents = repository.query([
|
|
|
|
|
{ '#e': eventIds, kinds: [NOTE, COMMENT, REACTION] },
|
|
|
|
|
])
|
|
|
|
|
const context = [...events, ...replyEvents]
|
|
|
|
|
|
2026-08-27 18:49:56 +00:00
|
|
|
const data = { events, context, relayByEventId } as DigestData
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
if (data.events.length > 0) {
|
|
|
|
|
await sendDigest(this.sub, await this.buildParameters(data))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Utilities
|
|
|
|
|
|
2026-08-27 18:49:56 +00:00
|
|
|
const buildLink = (event: TrustedEvent, handler: string, storedRelay?: string) => {
|
|
|
|
|
const relays = storedRelay ? [storedRelay] : []
|
|
|
|
|
|
2026-08-26 21:57:26 +00:00
|
|
|
const groupId = getTagValue(["h"], event.tags)
|
|
|
|
|
|
|
|
|
|
if (groupId && relays.length > 0) {
|
|
|
|
|
const relay = encodeRelay(relays[0])
|
|
|
|
|
const nevent = neventEncode({ id: event.id, relays })
|
|
|
|
|
return `${handler}spaces/${relay}/${encodeURIComponent(groupId)}?at=${event.created_at}#${nevent}`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
const nevent = neventEncode({ ...event, relays })
|
|
|
|
|
|
|
|
|
|
if (handler.includes('<bech32>')) {
|
|
|
|
|
return handler.replace('<bech32>', nevent)
|
|
|
|
|
} else {
|
|
|
|
|
return handler + nevent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 21:57:26 +00:00
|
|
|
const encodeRelay = (url: string) =>
|
|
|
|
|
encodeURIComponent(
|
|
|
|
|
normalizeRelayUrl(url)
|
|
|
|
|
.replace(/^wss:\/\//, '')
|
|
|
|
|
.replace(/\/$/, ''),
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
const displayProfileByPubkey = (pubkey: string) =>
|
|
|
|
|
displayProfile(profilesByPubkey.get().get(pubkey), displayPubkey(pubkey))
|
|
|
|
|
|
|
|
|
|
const renderEntity = (entity: string) => {
|
|
|
|
|
let display = entity.slice(0, 16) + '…'
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { type, data } = decode(entity)
|
|
|
|
|
|
|
|
|
|
if (type === 'npub') {
|
|
|
|
|
display = '@' + displayProfileByPubkey(data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type === 'nprofile') {
|
|
|
|
|
display = '@' + displayProfileByPubkey(data.pubkey)
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Pass
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return display
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getFormatter = () => {
|
|
|
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
|
|
|
dateStyle: 'short',
|
|
|
|
|
timeStyle: 'short',
|
|
|
|
|
timeZone: 'UTC',
|
|
|
|
|
})
|
2026-08-27 19:18:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Poll until the profile lands in the repository or hits a timeout. The load
|
|
|
|
|
// promise can resolve before sharedLoad writes the event to the store, so only
|
|
|
|
|
// the profile's presence here guarantees its avatar/name.
|
|
|
|
|
const waitForProfile = async (pubkey: string, timeoutMs = 5000) => {
|
|
|
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
if (profilesByPubkey.get().get(pubkey)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
await new Promise(r => setTimeout(r, 200))
|
|
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
}
|