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, normalizeRelayUrl, getParentId, NOTE, COMMENT, REACTION, displayProfile, displayPubkey, getTagValue, } from '@welshman/util' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, loadProfile, } from './repository.js' type DigestData = { events: TrustedEvent[] context: TrustedEvent[] relayByEventId: Map } 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 `${EVENT_VIEWER_URL}/` } buildParameters = async (data: DigestData) => { const getEventVariables = (event: TrustedEvent) => { const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 }) return { 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) 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, 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) const context = [...events] // For now, context == events (no reply loading) 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)) pubkeys.add(this.sub.pubkey) for (const pk of pubkeys) { try { await loadProfile(pk) await waitForProfile(pk) } catch { // pass } } const data = { events, context, relayByEventId } as DigestData if (data.events.length > 0) { await sendDigest(this.sub, await this.buildParameters(data)) } } } // Utilities const buildLink = (event: TrustedEvent, handler: string, storedRelay?: string) => { const relays = storedRelay ? [storedRelay] : [] 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('')) { return handler.replace('', nevent) } else { return handler + nevent } } 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', }) } // 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)) } }