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, getParentId, NOTE, COMMENT, REACTION, displayProfile, displayPubkey, } from '@welshman/util' import { Router } from '@welshman/router' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { profilesByPubkey, loadProfile, } from './repository.js' type DigestData = { events: TrustedEvent[] context: TrustedEvent[] } 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 return 'https://coracle.social/' } buildParameters = async (data: DigestData) => { const getEventVariables = (event: TrustedEvent) => { const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 }) return { Link: buildLink(event, handler), 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) const popular = sortBy((e) => -(repliesByParentId.get(e.id)?.length || 0), events).slice(0, 12) 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), Popular: popular.map((e) => getEventVariables(e)), HasPopular: popular.length > 0, TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))), } } sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => { const events = storedEvents.map(se => se.event) const context = [...events] // For now, context == events (no reply loading) // Load profiles for event authors const pubkeys = new Set(events.map(e => e.pubkey)) for (const pk of pubkeys) { try { await loadProfile(pk) } catch { // pass } } const data = { events, context } as DigestData if (data.events.length > 0) { await sendDigest(this.sub, await this.buildParameters(data)) } } } // Utilities const buildLink = (event: TrustedEvent, handler: string) => { const relays = Router.get().Event(event).getUrls() const nevent = neventEncode({ ...event, relays }) if (handler.includes('')) { return handler.replace('', nevent) } else { return handler + nevent } } 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', }) }