diff --git a/src/megaFeeds.ts b/src/megaFeeds.ts new file mode 100644 index 00000000..7b2b5137 --- /dev/null +++ b/src/megaFeeds.ts @@ -0,0 +1,201 @@ +import { Kind } from "./constants"; +import { getMegaFeed } from "./lib/feed"; +import { setLinkPreviews } from "./lib/notes"; +import { subsTo } from "./sockets"; +import { isRepostInCollection } from "./stores/note"; +import { + MegaFeedPage, + NostrEventContent, + NostrMentionContent, + NostrNoteActionsContent, + NostrNoteContent, + NostrStatsContent, + NostrUserContent, + NoteActions, + PrimalArticle, + PrimalNote, + TopZap, +} from "./types/primal"; +import { parseBolt11 } from "./utils"; +import { convertToNotesMega, convertToReadsMega } from "./stores/megaFeed"; +import { FeedRange } from "./pages/FeedQueryTest"; + +export type MegaFeedResults = { notes: PrimalNote[], reads: PrimalArticle[] }; + +export const fetchMegaFeed = ( + pubkey: string | undefined, + specification: any, + subId: string, + paging?: { + limit?: number, + until?: number, + since?: number, + offset?: number, + }, + excludeIds?: string[] + ) => { + return new Promise((resolve) => { + let page: MegaFeedPage = { + users: {}, + notes: [], + reads: [], + noteStats: {}, + mentions: {}, + noteActions: {}, + relayHints: {}, + topZaps: {}, + wordCount: {}, + since: 0, + until: 0, + sortBy: 'created_at', + }; + + const unsub = subsTo(subId, { + onEose: () => { + unsub(); + + const notes = convertToNotesMega(page); + const reads = convertToReadsMega(page); + + resolve({ notes, reads }); + }, + onEvent: (_, content) => { + updatePage(content, excludeIds || []); + } + }); + + const until = paging?.until || 0; + const limit = paging?.limit || 0; + + getMegaFeed(pubkey, specification, subId, until, limit); + + const updatePage = (content: NostrEventContent, excludeIds: string[]) => { + if (content.kind === Kind.FeedRange) { + const feedRange: FeedRange = JSON.parse(content.content || '{}'); + + page.since = feedRange.since; + page.until = feedRange.until; + page.sortBy = feedRange.order_by; + return; + } + + if (content.kind === Kind.Metadata) { + const user = content as NostrUserContent; + + page.users[user.pubkey] = { ...user }; + return; + } + + if ([Kind.Text, Kind.Repost].includes(content.kind)) { + const message = content as NostrNoteContent; + + const isRepost = message.kind === Kind.Repost; + + const isAlreadyIn = message.kind === Kind.Text && + excludeIds.find(id => id === message.id); + + + let isAlreadyReposted = isRepostInCollection(page.notes, message); + + if (isAlreadyIn || isAlreadyReposted) return; + + page.notes.push({ ...message }); + return; + } + + if (content.kind === Kind.NoteStats) { + const statistic = content as NostrStatsContent; + const stat = JSON.parse(statistic.content); + + page.noteStats[stat.event_id] = { ...stat }; + return; + } + + if (content.kind === Kind.Mentions) { + const mentionContent = content as NostrMentionContent; + const mention = JSON.parse(mentionContent.content); + + page.mentions[mention.id] = { ...mention}; + return; + } + + if (content.kind === Kind.NoteActions) { + const noteActionContent = content as NostrNoteActionsContent; + const noteActions = JSON.parse(noteActionContent.content) as NoteActions; + + page.noteActions[noteActions.event_id] = { ...noteActions }; + return; + } + + if (content.kind === Kind.LinkMetadata) { + const metadata = JSON.parse(content.content); + + const data = metadata.resources[0]; + if (!data) { + return; + } + + const preview = { + url: data.url, + title: data.md_title, + description: data.md_description, + mediaType: data.mimetype, + contentType: data.mimetype, + images: [data.md_image], + favicons: [data.icon_url], + }; + + setLinkPreviews(() => ({ [data.url]: preview })); + return; + } + + if (content?.kind === Kind.Zap) { + const zapTag = content.tags.find(t => t[0] === 'description'); + + if (!zapTag) return; + + const zapInfo = JSON.parse(zapTag[1] || '{}'); + + let amount = '0'; + + let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11'); + + if (bolt11Tag) { + try { + amount = `${parseBolt11(bolt11Tag[1]) || 0}`; + } catch (e) { + const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount'); + + amount = amountTag ? amountTag[1] : '0'; + } + } + + const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1]; + + const zap: TopZap = { + id: zapInfo.id, + amount: parseInt(amount || '0'), + pubkey: zapInfo.pubkey, + message: zapInfo.content, + eventId, + }; + + const oldZaps = page.topZaps[eventId]; + + if (oldZaps === undefined) { + page.topZaps[eventId] = [{ ...zap }]; + return; + } + + if (oldZaps.find(i => i.id === zap.id)) { + return; + } + + const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount); + + page.topZaps[eventId] = [ ...newZaps ]; + return; + } + }; + }); +}; diff --git a/src/stores/megaFeed.ts b/src/stores/megaFeed.ts new file mode 100644 index 00000000..ca1f4ace --- /dev/null +++ b/src/stores/megaFeed.ts @@ -0,0 +1,445 @@ +import { nip19 } from "nostr-tools"; +import { Kind } from "../constants"; +import { hexToNpub } from "../lib/keys"; +import { sanitize } from "../lib/notes"; +import { MegaFeedPage, MegaRepostInfo, NostrEvent, NostrNoteContent, PrimalArticle, PrimalNote, PrimalUser, TopZap } from "../types/primal"; +import { convertToUser, emptyUser } from "./profile"; + + +export const noActions = (id: string) => ({ + event_id: id, + liked: false, + replied: false, + reposted: false, + zapped: false, +}); + +export const encodeCoordinate = (event: NostrNoteContent, forceKind?: Kind) => { + + const identifier = (event.tags.find(t => t[0] === 'd') || [])[1]; + const pubkey = event.pubkey; + const kind = forceKind || event.kind; + + const coordinate = `${kind}:${identifier}:${pubkey}`; + const naddr = nip19.naddrEncode({ kind, pubkey, identifier }); + + return { coordinate, naddr }; +} + + +export const extractRepostInfo: MegaRepostInfo = (page, message) => { + const user = page?.users[message.pubkey]; + const userMeta = JSON.parse(user?.content || '{}'); + const stat = page?.noteStats[message.id]; + + return { + user: { + id: user?.id || '', + pubkey: user?.pubkey || message.pubkey, + npub: hexToNpub(user?.pubkey || message.pubkey), + name: (userMeta.name || user?.pubkey) as string, + about: (userMeta.about || '') as string, + picture: (userMeta.picture || '') as string, + nip05: (userMeta.nip05 || '') as string, + banner: (userMeta.banner || '') as string, + displayName: (userMeta.display_name || '') as string, + location: (userMeta.location || '') as string, + lud06: (userMeta.lud06 || '') as string, + lud16: (userMeta.lud16 || '') as string, + website: (userMeta.website || '') as string, + tags: user?.tags || [], + }, + note: { + id: message.id, + pubkey: message.pubkey, + created_at: message.created_at || 0, + tags: message.tags, + content: sanitize(message.content), + kind: message.kind, + sig: message.sig, + likes: stat?.likes || 0, + mentions: stat?.mentions || 0, + reposts: stat?.reposts || 0, + replies: stat?.replies || 0, + zaps: stat?.zaps || 0, + score: stat?.score || 0, + score24h: stat?.score24h || 0, + satszapped: stat?.satszapped || 0, + noteId: nip19.noteEncode(message.id), + noteActions: (page.noteActions && page.noteActions[message.id]) || noActions(message.id), + relayHints: page.relayHints, + }, + } +}; + +export const extractReplyTo = (tags: string[][]) => { + let replyTo: string[] = []; + + // Determine parent by finding the `e` tag with `reply` then `root` as `marker` + // If both fail return the last `e` tag + for (let i=0; i t[0] === 'e' && t[3] !== 'mention'); + + if (eTags.length === 1) { + replyTo = [...eTags[0]]; + } + else if (eTags.length > 1){ + replyTo = [...eTags[eTags.length - 1]]; + } + } + + return replyTo; +} + +export const extractMentions = (page: MegaFeedPage, note: NostrNoteContent) => { + + const mentionIds = Object.keys(page.mentions || {}); + const userMentionIds = note.tags.reduce((acc, t) => t[0] === 'p' ? [...acc, t[1]] : acc, []); + const wordCounts = page.wordCount || {}; + + let mentionedNotes: Record = {}; + let mentionedUsers: Record = {}; + let mentionedHighlights: Record = {}; + let mentionedArticles: Record = {}; + + for (let i = 0;i { + switch (tag[0]) { + case 't': + article.tags.push(tag[1]); + break; + case 'title': + article.title = tag[1]; + break; + case 'summary': + article.summary = tag[1]; + break; + case 'image': + article.image = tag[1]; + break; + case 'published': + article.published = parseInt(tag[1]); + break; + case 'client': + article.client = tag[1]; + break; + default: + break; + } + }); + + mentionedArticles[article.naddr] = { ...article }; + } + + if ([Kind.Highlight].includes(mention.kind)) { + mentionedHighlights[mentionId] = { + user: convertToUser(page.users[mention.pubkey] || emptyUser(mention.pubkey)), + event: { ...mention }, + } + } + } + + if (userMentionIds && userMentionIds.length > 0) { + for (let i = 0;i { + + if (page === undefined) { + return []; + } + + let i = 0; + + let notes: PrimalNote[] = []; + + for (i=0;i t[0] === 'p' ? [...acc, t[1]] : acc, []); + const replyTo = extractReplyTo(tags); + + // include senders of top zaps into mentioned users + for(let i=0; i { + if (page === undefined) { + return []; + } + + let i = 0; + + let reads: PrimalArticle[] = []; + + for (i=0;i t[0] === 'p' ? [...acc, t[1]] : acc, []); + const replyTo = extractReplyTo(tags); + + // include senders of top zaps into mentioned users + for(let i=0; i t[0] === 'published_at') || [])[1] || `${read.created_at}` || '0'), + content: sanitize(read.content || ''), + user: author, + topZaps, + naddr, + noteId: naddr, + coordinate, + msg: { + ...read, + kind: Kind.LongForm, + }, + mentionedNotes, + mentionedUsers, + mentionedHighlights, + mentionedArticles, + wordCount, + noteActions: (page.noteActions && page.noteActions[read.id]) ?? noActions(read.id), + likes: stat?.likes || 0, + mentions: stat?.mentions || 0, + reposts: stat?.reposts || 0, + replies: stat?.replies || 0, + zaps: stat?.zaps || 0, + score: stat?.score || 0, + score24h: stat?.score24h || 0, + satszapped: stat?.satszapped || 0, + relayHints: page.relayHints, + }; + + tags.forEach(tag => { + switch (tag[0]) { + case 't': + newRead.tags.push(tag[1]); + break; + case 'title': + newRead.title = tag[1]; + break; + case 'summary': + newRead.summary = tag[1]; + break; + case 'image': + newRead.image = tag[1]; + break; + case 'published': + newRead.published = parseInt(tag[1]); + break; + case 'client': + newRead.client = tag[1]; + break; + default: + break; + } + }); + + reads.push(newRead); + } + + return reads; +}; diff --git a/src/types/primal.d.ts b/src/types/primal.d.ts index 2d3f2100..fe5a4a21 100644 --- a/src/types/primal.d.ts +++ b/src/types/primal.d.ts @@ -396,9 +396,27 @@ export type FeedPage = { topZaps: Record, since?: number, until?: number, + sortBy?: string, wordCount?: Record, }; +export type MegaFeedPage = { + users: { + [pubkey: string]: NostrUserContent, + }, + notes: NostrNoteContent[], + reads: NostrNoteContent[], + noteStats: NostrPostStats, + mentions: Record, + noteActions: Record, + relayHints: Record, + topZaps: Record, + since: number, + until: number, + sortBy: string, + wordCount: Record, +}; + export type TrendingNotesStore = { users: { [pubkey: string]: NostrUserContent, @@ -693,6 +711,8 @@ export type PrimalZap = { export type RepostInfo = (page: FeedPage, message: NostrNoteContent) => PrimalRepost; +export type MegaRepostInfo = (page: MegaFeedPage, message: NostrNoteContent) => PrimalRepost; + export type ExploreFeedPayload = { timeframe: string, scope: string,