mailship/src/digest.ts

191 lines
5.5 KiB
TypeScript
Raw Normal View History

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,
getParentId,
NOTE,
COMMENT,
REACTION,
displayProfile,
displayPubkey,
2026-08-26 21:57:26 +00:00
getTagValue,
} 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'
import {
profilesByPubkey,
loadProfile,
repository,
} from './repository.js'
type DigestData = {
events: TrustedEvent[]
context: TrustedEvent[]
2026-08-27 18:49:56 +00:00
relayByEventId: Map<string, string>
}
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}/`
}
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)),
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)
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),
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]),
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]))
// 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)
for (const pk of pubkeys) {
try {
await loadProfile(pk)
2026-08-27 19:18:52 +00:00
await waitForProfile(pk)
} catch {
// pass
}
}
// 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
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}`
}
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(/\/$/, ''),
)
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))
}
}