From 4138e12d5e9cc2e9efd43156b175d6ee908f01e5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 13 May 2026 15:33:06 -0500 Subject: [PATCH] Add feed toggles for reactions and zaps, rendered as overlays on the target post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new Feed-section toggles in Content Settings, both disabled by default (existing users don't suddenly get a noisy feed of every like and zap their follows hand out): - Reactions (kind 7) - Zaps (kind 9735 Lightning + kind 8333 on-chain — one combined toggle since users don't think in terms of payment rails) When enabled, reactions and zaps from followed users surface in the Follows feed as a header above the target post — same shape as the existing kind 6 / 16 repost overlay ("X reacted to" / "X zapped 1,234 sats" / "X reposted"). The reaction overlay renders the kind 7 event's actual emoji via ReactionEmoji (handling unicode, "+"/"-" likes, and NIP-30 custom emojis) rather than a generic smiley. The target event is unwrapped by useFeed and useProfileFeed in a single batched ids query, then deduped so a direct post always wins over any overlay for the same event. The verb in each overlay header is a Link to the underlying reaction / repost / zap event's /:nip19 page, matching the new behavior in Notifications. Reposts now carry the wrapper event (`repostEvent`) through FeedItem so this works for them too without a separate fetch. Global feed continues to exclude reposts, and now also excludes reactions and zaps for the same reason — they need an author filter to be useful and would otherwise drown out direct posts. --- src/App.tsx | 2 + src/components/Feed.tsx | 16 +- src/components/NoteCard.tsx | 132 ++++++++-- src/components/PeopleListDetailContent.tsx | 1 + src/contexts/AppContext.ts | 4 + src/hooks/useFeed.ts | 282 +++++++++++++-------- src/hooks/useProfileFeed.ts | 8 +- src/lib/extraKinds.ts | 23 ++ src/lib/feedUtils.ts | 58 ++++- src/lib/schemas.ts | 2 + src/lib/sidebarItems.tsx | 3 + src/pages/FollowPage.tsx | 1 + src/pages/ProfilePage.tsx | 2 + src/pages/SearchPage.tsx | 2 +- src/test/TestApp.tsx | 2 + 15 files changed, 390 insertions(+), 148 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1b630fb6..76298f7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,8 @@ const hardcodedConfig: AppConfig = { feedIncludeComments: true, feedIncludeReposts: true, feedIncludeGenericReposts: true, + feedIncludeReactions: false, + feedIncludeZaps: false, feedIncludeArticles: true, showArticles: true, showHighlights: true, diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 5fb2548e..bb73e692 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -28,7 +28,7 @@ import { useCuratorFollowList } from '@/hooks/useCuratorFollowList'; import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { diversifyFeedPages } from '@/lib/feedDiversity'; -import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils'; +import { isRepostKind, shouldHideFeedEvent, feedItemKey } from '@/lib/feedUtils'; import { isEventMuted } from '@/lib/muteHelpers'; import { SubHeaderBar } from '@/components/SubHeaderBar'; import { ARC_OVERHANG_PX } from '@/components/ArcBackground'; @@ -213,7 +213,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee return (rawData.pages as unknown as { items: FeedItem[] }[]) .flatMap((page) => page.items) .filter((item) => { - const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id; + const key = feedItemKey(item); if (!key || seen.has(key)) return false; seen.add(key); if (shouldHideFeedEvent(item.event)) return false; @@ -310,9 +310,12 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
{feedItems.map((item: FeedItem) => ( ))} {hasNextPage && ( @@ -417,7 +420,7 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { return rawData.pages .flatMap((page) => page.items) .filter((item) => { - const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id; + const key = feedItemKey(item); if (!key || seen.has(key)) return false; seen.add(key); if (shouldHideFeedEvent(item.event)) return false; @@ -449,9 +452,12 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
{feedItems.map((item) => ( ))} {hasNextPage && ( diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 2a233a10..985a9063 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -252,6 +252,12 @@ interface NoteCardProps { className?: string; /** If set, shows a "Reposted by" header with this pubkey. */ repostedBy?: string; + /** Optional: the underlying kind 6 / 16 repost event. When provided, the "reposted" verb in the header links to its nevent. */ + repostEvent?: NostrEvent; + /** If set, shows a "Reacted by" header. The event is the underlying kind 7 reaction (used for linking to its nevent). */ + reactedBy?: { event: NostrEvent; pubkey: string }; + /** If set, shows a "Zapped" header. The event is the underlying kind 9735 / 8333 zap; sats is the parsed amount (0 if unknown). */ + zappedBy?: { event: NostrEvent; pubkey: string; sats: number }; /** If true, hide action buttons (used for embeds). */ compact?: boolean; /** If true, render in threaded ancestor style: connector line below avatar, no bottom border. */ @@ -305,6 +311,9 @@ export const NoteCard = memo(function NoteCard({ event, className, repostedBy, + repostEvent, + reactedBy, + zappedBy, compact, threaded, threadedLineClassName, @@ -908,15 +917,46 @@ export const NoteCard = memo(function NoteCard({ ); } - // Repost header — shown above activity-card layouts (reaction/repost/zap/poll vote) - // when this event was surfaced via a kind 6 / 16 repost. The normal note layout - // renders this inline below; activity-card branches return early so they need it here. - const repostHeader = repostedBy ? ( + // Wrapper header — shown above activity-card layouts (reaction/repost/zap/poll vote) + // and above the normal layout when this event was surfaced via a repost, + // reaction, or zap. The activity-card branches return early so they need + // it computed up here. + const wrapperHeader = reactedBy ? ( + + + + } + action="reacted to" + actionEvent={reactedBy.event} + /> + ) : zappedBy ? ( + 0 ? ( + + {formatNumber(zappedBy.sats)} {zappedBy.sats === 1 ? 'sat' : 'sats'} + + ) : undefined} + /> + ) : repostedBy ? ( ) : undefined; @@ -925,7 +965,7 @@ export const NoteCard = memo(function NoteCard({ const iconSize = threaded || threadedLast ? "size-10" : "size-11"; return ( @@ -946,7 +986,7 @@ export const NoteCard = memo(function NoteCard({ const iconSize = threaded || threadedLast ? "size-10" : "size-11"; return ( @@ -969,7 +1009,7 @@ export const NoteCard = memo(function NoteCard({ const iconSize = threaded || threadedLast ? "size-10" : "size-11"; return ( @@ -998,7 +1038,7 @@ export const NoteCard = memo(function NoteCard({ const iconSize = threaded || threadedLast ? "size-10" : "size-11"; return ( e.stopPropagation()}> @@ -1031,7 +1071,7 @@ export const NoteCard = memo(function NoteCard({ // ── Threaded layout (with or without connector line) ── if (threaded || threadedLast) { // Kind action header (e.g. "updated their badges") — same logic as normal layout - const threadedKindHeader = !repostedBy && !hideKindHeader && KIND_HEADER_MAP[event.kind] + const threadedKindHeader = !repostedBy && !reactedBy && !zappedBy && !hideKindHeader && KIND_HEADER_MAP[event.kind] ? (() => { const cfg = KIND_HEADER_MAP[event.kind]; const isLive = event.kind === 30311 && getEffectiveStreamStatus(event) === "live"; @@ -1062,7 +1102,7 @@ export const NoteCard = memo(function NoteCard({ onClick={handleCardClick} onAuxClick={handleAuxClick} > - {threadedKindHeader} + {wrapperHeader ?? threadedKindHeader}
{avatarElement} @@ -1101,14 +1141,9 @@ export const NoteCard = memo(function NoteCard({ onClick={handleCardClick} onAuxClick={handleAuxClick} > - {/* Action header — repost takes priority, otherwise derived from event kind */} - {repostedBy ? ( - + {/* Action header — wrapper (repost/reaction/zap) takes priority, otherwise derived from event kind */} + {wrapperHeader ? ( + wrapperHeader ) : ( !hideKindHeader && KIND_HEADER_MAP[event.kind] && (() => { @@ -1699,6 +1734,14 @@ export interface EventActionHeaderProps { pubkey: string; /** Lucide icon component shown to the left of the author name. */ icon: React.ComponentType<{ className?: string }>; + /** + * Optional pre-rendered icon node that takes priority over `icon`. Use + * this when the icon isn't a generic Lucide component — e.g. a reaction + * emoji (``) where the visual is data-driven. The node + * is rendered as-is inside the same `w-11` slot, so it should size + * itself (e.g. `className="size-4"`). + */ + iconNode?: ReactNode; /** Optional className for the icon (defaults to text-primary). */ iconClassName?: string; /** Verb phrase shown after the author name, e.g. "hid a" or "is streaming". */ @@ -1707,6 +1750,19 @@ export interface EventActionHeaderProps { noun?: string; /** Route to link the noun to, e.g. "/treasures". */ nounRoute?: string; + /** + * Optional underlying event (reaction, zap, repost) that the verb should + * link to. When provided, the entire verb (and the optional `extra` + * suffix) is wrapped in a Link pointing at `/${nevent}` so the user + * can navigate directly to the underlying event detail page. + */ + actionEvent?: NostrEvent; + /** + * Optional inline content appended after the verb — used for the sats + * amount on zap headers ("zapped · 1,234 sats"). Rendered inside the + * same Link as the verb when `actionEvent` is set. + */ + extra?: ReactNode; } /** Static config for deriving the action header from an event's kind and tags. */ @@ -1922,24 +1978,40 @@ const KIND_HEADER_MAP: Record = { export function EventActionHeader({ pubkey, icon: Icon, + iconNode, iconClassName, action, noun, nounRoute, + actionEvent, + extra, }: EventActionHeaderProps) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || author.data?.metadata?.display_name || genUserName(pubkey); const url = useProfileUrl(pubkey, author.data?.metadata); + const actionHref = useMemo( + () => (actionEvent ? `/${encodeEventAddress(actionEvent)}` : undefined), + [actionEvent], + ); + + const verbContent = ( + <> + {action} + {extra ? <> {extra} : null} + + ); return (
- + {iconNode ?? ( + + )}
{author.isLoading ? ( @@ -1962,7 +2034,17 @@ export function EventActionHeader({ )} - {action} + {actionHref ? ( + e.stopPropagation()} + > + {verbContent} + + ) : ( + verbContent + )} {noun && nounRoute && ( <> {" "} diff --git a/src/components/PeopleListDetailContent.tsx b/src/components/PeopleListDetailContent.tsx index bd47e936..bc0feb2e 100644 --- a/src/components/PeopleListDetailContent.tsx +++ b/src/components/PeopleListDetailContent.tsx @@ -164,6 +164,7 @@ export function PeopleListFeedTab({ pubkeys, tabKey }: { pubkeys: string[]; tabK key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id} event={item.event} repostedBy={item.repostedBy} + repostEvent={item.repostEvent} /> ))} {hasNextPage && ( diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index e81edbf9..7eb0038b 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -44,6 +44,10 @@ export interface FeedSettings { feedIncludeReposts: boolean; /** Include generic reposts (kind 16) in the feed */ feedIncludeGenericReposts: boolean; + /** Include reactions (kind 7) in the feed, rendered as "X reacted to" overlays on the target event. Default: false. */ + feedIncludeReactions: boolean; + /** Include zaps (Lightning kind 9735 + on-chain kind 8333) in the feed, rendered as "X zapped" overlays on the target event. Default: false. */ + feedIncludeZaps: boolean; /** Include long-form articles (kind 30023) in the feed */ feedIncludeArticles: boolean; /** Show Articles (kind 30023) link in sidebar */ diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index 73f64a11..60ff904f 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -7,10 +7,18 @@ import { useFollowList } from './useFollowActions'; import { useMutedAuthorFilter } from './useMutedAuthorFilter'; import { parseAuthorEvent } from './useAuthor'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; -import { getPaginationCursor, parseRepostContent, isRepostKind, type FeedItem } from '@/lib/feedUtils'; +import { + getPaginationCursor, + parseRepostContent, + isRepostKind, + isReactionKind, + isZapKind, + type FeedItem, +} from '@/lib/feedUtils'; import { isReplyEvent } from '@/lib/nostrEvents'; import { setProfileCached } from '@/lib/profileCache'; import { getStorageKey } from '@/lib/storageKey'; +import { getZapAmountSats, getZapSenderPubkey, getTargetEventId } from '@/lib/zapHelpers'; import type { NostrEvent } from '@nostrify/nostrify'; const PAGE_SIZE = 15; @@ -106,6 +114,151 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use } } + /** + * Turn a list of raw events into FeedItems, unwrapping reposts / + * reactions / zaps so that the target event becomes the FeedItem's + * primary `event` and the wrapper is surfaced as an overlay + * (repostedBy / reactedBy / zappedBy). Any wrapper whose target + * isn't in `events` is fetched in a single batched query. + */ + async function buildFeedItems(events: NostrEvent[]): Promise { + const items: FeedItem[] = []; + + // Map of target-event id → list of wrappers that need it. A single + // target can have multiple wrappers (e.g. several reactions to one + // post), so we store an array. + type PendingWrapper = + | { type: 'repost'; event: NostrEvent } + | { type: 'reaction'; event: NostrEvent } + | { type: 'zap'; event: NostrEvent }; + const missingTargets = new Map(); + + const queueMissing = (id: string, wrapper: PendingWrapper) => { + const existing = missingTargets.get(id); + if (existing) existing.push(wrapper); + else missingTargets.set(id, [wrapper]); + }; + + // Index events by id so we can resolve targets that arrived in the + // same page without an extra query. + const eventsById = new Map(); + for (const ev of events) eventsById.set(ev.id, ev); + + for (const ev of events) { + if (isRepostKind(ev.kind)) { + // Kind 6 / 16 — repost. Prefer the embedded JSON; fall back to + // resolving the `e` tag. + const embedded = parseRepostContent(ev); + if (embedded && embedded.created_at <= now) { + items.push({ event: embedded, repostedBy: ev.pubkey, repostEvent: ev, sortTimestamp: ev.created_at }); + continue; + } + const targetId = getTargetEventId(ev); + if (!targetId) continue; + const resolved = eventsById.get(targetId); + if (resolved && resolved.created_at <= now) { + items.push({ event: resolved, repostedBy: ev.pubkey, repostEvent: ev, sortTimestamp: ev.created_at }); + } else { + queueMissing(targetId, { type: 'repost', event: ev }); + } + } else if (isReactionKind(ev.kind)) { + // Kind 7 — reaction. The target is in the last `e` tag (NIP-25). + const eTags = ev.tags.filter(([n]) => n === 'e'); + const targetId = eTags[eTags.length - 1]?.[1]; + if (!targetId) continue; + const resolved = eventsById.get(targetId); + if (resolved && resolved.created_at <= now) { + items.push({ + event: resolved, + reactedBy: { event: ev, pubkey: ev.pubkey }, + sortTimestamp: ev.created_at, + }); + } else { + queueMissing(targetId, { type: 'reaction', event: ev }); + } + } else if (isZapKind(ev.kind)) { + // Kind 9735 Lightning receipt or kind 8333 on-chain attestation. + const targetId = getTargetEventId(ev); + if (!targetId) continue; + const senderPubkey = getZapSenderPubkey(ev); + const sats = getZapAmountSats(ev); + const resolved = eventsById.get(targetId); + if (resolved && resolved.created_at <= now) { + items.push({ + event: resolved, + zappedBy: { event: ev, pubkey: senderPubkey, sats }, + sortTimestamp: ev.created_at, + }); + } else { + queueMissing(targetId, { type: 'zap', event: ev }); + } + } else { + // Direct post — kind 1, 1068, 34236, etc. + items.push({ event: ev, sortTimestamp: ev.created_at }); + } + } + + // Single batched fetch for all missing target events. + if (missingTargets.size > 0) { + try { + const ids = [...missingTargets.keys()]; + const originals = await nostr.query( + [{ ids, limit: ids.length }], + { signal }, + ); + for (const original of originals) { + if (original.created_at > now) continue; + const wrappers = missingTargets.get(original.id); + if (!wrappers) continue; + for (const w of wrappers) { + if (w.type === 'repost') { + items.push({ event: original, repostedBy: w.event.pubkey, repostEvent: w.event, sortTimestamp: w.event.created_at }); + } else if (w.type === 'reaction') { + items.push({ + event: original, + reactedBy: { event: w.event, pubkey: w.event.pubkey }, + sortTimestamp: w.event.created_at, + }); + } else { + items.push({ + event: original, + zappedBy: { + event: w.event, + pubkey: getZapSenderPubkey(w.event), + sats: getZapAmountSats(w.event), + }, + sortTimestamp: w.event.created_at, + }); + } + } + } + } catch { + // timeout or abort — just skip wrappers whose targets couldn't be fetched + } + } + + return items; + } + + /** + * Deduplicate FeedItems by event id. Direct posts win over any + * overlay (repost / reaction / zap), so the user sees the original + * once with full action buttons rather than as a passive overlay. + */ + function dedupeFeedItems(items: FeedItem[]): FeedItem[] { + const seen = new Map(); + for (const item of items) { + const existing = seen.get(item.event.id); + const isDirect = !item.repostedBy && !item.reactedBy && !item.zappedBy; + if (!existing) { + seen.set(item.event.id, item); + } else if (isDirect && (existing.repostedBy || existing.reactedBy || existing.zappedBy)) { + seen.set(item.event.id, item); + } + } + return Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp); + } + if (tab === 'communities' && communityPubkeys.length > 0) { // Communities feed — posts from community members with NIP-05 verification const fetchLimit = !feedSettings.followsFeedShowReplies ? PAGE_SIZE * OVER_FETCH_MULTIPLIER : PAGE_SIZE; @@ -180,64 +333,17 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use const validFilteredEvents = filteredEvents.filter((ev) => ev.created_at <= now); const oldestQueryTimestamp = getPaginationCursor(validFilteredEvents); - // Process reposts same as follows feed - const items: FeedItem[] = []; - const repostMissingIds: string[] = []; - const repostMap = new Map(); + // Unwrap reposts / reactions / zaps so the target event renders + // with the wrapper as an overlay header. + const items = await buildFeedItems(validFilteredEvents); - for (const ev of validFilteredEvents) { - if (isRepostKind(ev.kind)) { - // Handle reposts (kind 6 for notes, kind 16 for generic) - const embedded = parseRepostContent(ev); - if (embedded && embedded.created_at <= now) { - items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at }); - } else { - const repostedId = ev.tags.find(([name]) => name === 'e')?.[1]; - if (repostedId) { - repostMissingIds.push(repostedId); - repostMap.set(repostedId, ev); - } - } - } else { - // Kind 1 and extra kinds — direct post - items.push({ event: ev, sortTimestamp: ev.created_at }); - } - } - - // Fetch any missing reposted events in a single query - if (repostMissingIds.length > 0) { - try { - const originals = await nostr.query( - [{ ids: repostMissingIds, limit: repostMissingIds.length }], - { signal }, - ); - for (const original of originals) { - const repost = repostMap.get(original.id); - if (repost && original.created_at <= now) { - items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at }); - } - } - } catch { - // timeout or abort — just skip the missing reposts - } - } - - // Deduplicate - const seen = new Map(); - for (const item of items) { - const existing = seen.get(item.event.id); - if (!existing) { - seen.set(item.event.id, item); - } else if (!item.repostedBy && existing.repostedBy) { - seen.set(item.event.id, item); - } - } - - let dedupedItems = Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp); + let dedupedItems = dedupeFeedItems(items); // Filter replies if the user has disabled them if (!feedSettings.followsFeedShowReplies) { - dedupedItems = dedupedItems.filter((item) => item.repostedBy || !isReplyEvent(item.event)); + dedupedItems = dedupedItems.filter( + (item) => item.repostedBy || item.reactedBy || item.zappedBy || !isReplyEvent(item.event), + ); } // Seed event cache so embedded note previews resolve instantly. @@ -268,63 +374,17 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use const validEvents = rawEvents.filter((ev) => ev.created_at <= now); const oldestQueryTimestamp = getPaginationCursor(validEvents); - const items: FeedItem[] = []; - const repostMissingIds: string[] = []; - const repostMap = new Map(); + // Unwrap reposts / reactions / zaps so the target event renders + // with the wrapper as an overlay header. + const items = await buildFeedItems(validEvents); - for (const ev of validEvents) { - if (isRepostKind(ev.kind)) { - // Handle reposts (kind 6 for notes, kind 16 for generic) - const embedded = parseRepostContent(ev); - if (embedded && embedded.created_at <= now) { - items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at }); - } else { - const repostedId = ev.tags.find(([name]) => name === 'e')?.[1]; - if (repostedId) { - repostMissingIds.push(repostedId); - repostMap.set(repostedId, ev); - } - } - } else { - // Kind 1, 1068, 3367, 34236, 37516, etc. — direct post / extra kinds - items.push({ event: ev, sortTimestamp: ev.created_at }); - } - } - - // Fetch any missing reposted events in a single query - if (repostMissingIds.length > 0) { - try { - const originals = await nostr.query( - [{ ids: repostMissingIds, limit: repostMissingIds.length }], - { signal }, - ); - for (const original of originals) { - const repost = repostMap.get(original.id); - if (repost && original.created_at <= now) { - items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at }); - } - } - } catch { - // timeout or abort — just skip the missing reposts - } - } - - // Deduplicate - const seen = new Map(); - for (const item of items) { - const existing = seen.get(item.event.id); - if (!existing) { - seen.set(item.event.id, item); - } else if (!item.repostedBy && existing.repostedBy) { - seen.set(item.event.id, item); - } - } - - let dedupedItems = Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp); + let dedupedItems = dedupeFeedItems(items); // Filter replies if the user has disabled them if (!feedSettings.followsFeedShowReplies) { - dedupedItems = dedupedItems.filter((item) => item.repostedBy || !isReplyEvent(item.event)); + dedupedItems = dedupedItems.filter( + (item) => item.repostedBy || item.reactedBy || item.zappedBy || !isReplyEvent(item.event), + ); } // Seed event cache so embedded note previews resolve instantly. @@ -332,8 +392,10 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use return { items: dedupedItems, oldestQueryTimestamp, rawCount: validEvents.length }; } else { - // Global feed — all enabled kinds except reposts (too noisy without author filter) - const globalKinds = allKinds.filter((k) => !isRepostKind(k)); + // Global feed — all enabled kinds except reposts / reactions / zaps, + // which are too noisy without an author filter and require an extra + // unwrap step. Users will see those overlays on the Follows tab. + const globalKinds = allKinds.filter((k) => !isRepostKind(k) && !isReactionKind(k) && !isZapKind(k)); const filter: Record = { kinds: globalKinds, limit: PAGE_SIZE, ...tagFilters }; // Use hot sorting on the homepage Global tab for better content quality, // but not on kind-specific pages that pass custom kinds. diff --git a/src/hooks/useProfileFeed.ts b/src/hooks/useProfileFeed.ts index 2ab25b4e..27483719 100644 --- a/src/hooks/useProfileFeed.ts +++ b/src/hooks/useProfileFeed.ts @@ -149,7 +149,7 @@ export function useProfileFeed(pubkey: string | undefined, activeTab: ProfileTab // Handle reposts (kind 6 for notes, kind 16 for generic) const embedded = parseRepostContent(ev); if (embedded && embedded.created_at <= now) { - items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at }); + items.push({ event: embedded, repostedBy: ev.pubkey, repostEvent: ev, sortTimestamp: ev.created_at }); } else { const repostedId = ev.tags.find(([name]) => name === 'e')?.[1]; if (repostedId) { @@ -173,7 +173,7 @@ export function useProfileFeed(pubkey: string | undefined, activeTab: ProfileTab for (const original of originals) { const repost = repostMap.get(original.id); if (repost && original.created_at <= now) { - items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at }); + items.push({ event: original, repostedBy: repost.pubkey, repostEvent: repost, sortTimestamp: repost.created_at }); } } } catch { @@ -360,7 +360,7 @@ export function useTabFeed( if (isRepostKind(ev.kind)) { const embedded = parseRepostContent(ev); if (embedded && embedded.created_at <= now) { - items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at }); + items.push({ event: embedded, repostedBy: ev.pubkey, repostEvent: ev, sortTimestamp: ev.created_at }); } else { const repostedId = ev.tags.find(([name]) => name === 'e')?.[1]; if (repostedId) { @@ -382,7 +382,7 @@ export function useTabFeed( for (const original of originals) { const repost = repostMap.get(original.id); if (repost && original.created_at <= now) { - items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at }); + items.push({ event: original, repostedBy: repost.pubkey, repostEvent: repost, sortTimestamp: repost.created_at }); } } } catch { diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index bf62c6d4..98ca2ddd 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -119,6 +119,29 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ section: 'feed', feedOnly: true, }, + { + kind: 7, + id: 'reactions', + feedKey: 'feedIncludeReactions', + label: 'Reactions', + description: 'People reacting to posts (likes and emoji reactions). Disabled by default.', + addressable: false, + section: 'feed', + feedOnly: true, + }, + { + kind: 9735, + id: 'zaps', + feedKey: 'feedIncludeZaps', + // Combine Lightning (9735) and on-chain Bitcoin (8333) zaps into a single + // toggle so users don't have to think about which rail was used. + extraFeedKinds: [8333], + label: 'Zaps', + description: 'People zapping posts (Lightning and on-chain Bitcoin). Disabled by default.', + addressable: false, + section: 'feed', + feedOnly: true, + }, { kind: 30023, id: 'articles', diff --git a/src/lib/feedUtils.ts b/src/lib/feedUtils.ts index bbf633f7..46847a85 100644 --- a/src/lib/feedUtils.ts +++ b/src/lib/feedUtils.ts @@ -48,6 +48,22 @@ export function isRepostKind(kind: number): boolean { return REPOST_KINDS.has(kind); } +/** The set of kind numbers that represent reactions. */ +export const REACTION_KINDS = new Set([7]); + +/** Check if a kind number is a reaction kind (7). */ +export function isReactionKind(kind: number): boolean { + return REACTION_KINDS.has(kind); +} + +/** The set of kind numbers that represent zap events (Lightning + on-chain). */ +export const ZAP_KINDS = new Set([9735, 8333]); + +/** Check if a kind number is a zap kind (9735 Lightning or 8333 on-chain). */ +export function isZapKind(kind: number): boolean { + return ZAP_KINDS.has(kind); +} + /** * Returns the correct repost kind for a given event. * Kind 6 is only for reposting kind 1 text notes; kind 16 is for everything else. @@ -56,16 +72,52 @@ export function getRepostKind(originalEventKind: number): number { return originalEventKind === 1 ? 6 : 16; } -/** A feed item — either a direct post or a repost wrapping the original event. */ +/** Overlay describing a reaction (kind 7) made to a target event. */ +export interface ReactionOverlay { + /** The reaction event itself (used for linking to the underlying nevent). */ + event: NostrEvent; + /** Pubkey of the person who reacted. */ + pubkey: string; +} + +/** Overlay describing a zap (kind 9735 Lightning or kind 8333 on-chain). */ +export interface ZapOverlay { + /** The zap event itself (used for linking to the underlying nevent). */ + event: NostrEvent; + /** Pubkey of the sender (resolved through P-tag / description / event.pubkey). */ + pubkey: string; + /** Zap amount in sats. May be 0 if unparseable. */ + sats: number; +} + +/** A feed item — either a direct post, a repost, a reaction, or a zap wrapping the original event. */ export interface FeedItem { - /** The event to display (original note). */ + /** The event to display (original note / target event). */ event: NostrEvent; /** If this item is a repost, the pubkey of the person who reposted it. */ repostedBy?: string; - /** Sort timestamp — uses the repost timestamp when present for correct ordering. */ + /** If this item is a repost and we have the wrapper event, the kind 6 / 16 repost event itself (used for linking "reposted" to its nevent). */ + repostEvent?: NostrEvent; + /** If this item is a reaction overlay, the reaction event + actor pubkey. */ + reactedBy?: ReactionOverlay; + /** If this item is a zap overlay, the zap event + sender pubkey + amount. */ + zappedBy?: ZapOverlay; + /** Sort timestamp — uses the wrapper event's timestamp when present for correct ordering. */ sortTimestamp: number; } +/** + * Compute a stable React key / dedup key for a feed item. The same target + * event can appear with multiple wrappers (a repost AND a reaction AND a + * zap), so the key incorporates the wrapper event id when present. + */ +export function feedItemKey(item: FeedItem): string { + if (item.reactedBy) return `reaction-${item.reactedBy.event.id}-${item.event.id}`; + if (item.zappedBy) return `zap-${item.zappedBy.event.id}-${item.event.id}`; + if (item.repostedBy) return `repost-${item.repostedBy}-${item.event.id}`; + return item.event.id; +} + /** d-tags reserved by NIP-51 for other purposes — hide these kind 30000 events from feeds. */ const DEPRECATED_DTAGS = new Set(['mute', 'pin', 'bookmark', 'communities']); diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 37d0be47..c04206aa 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -137,6 +137,8 @@ export const FeedSettingsSchema = z.looseObject({ feedIncludeComments: z.boolean().optional(), feedIncludeReposts: z.boolean().optional(), feedIncludeGenericReposts: z.boolean().optional(), + feedIncludeReactions: z.boolean().optional(), + feedIncludeZaps: z.boolean().optional(), feedIncludeArticles: z.boolean().optional(), showArticles: z.boolean().optional(), showHighlights: z.boolean().optional(), diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index c7e11e3f..cb42af6a 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -38,6 +38,7 @@ import { Stars, TrendingUp, User, + Zap, } from "lucide-react"; import { CardsIcon } from "@/components/icons/CardsIcon"; import { ChestIcon } from "@/components/icons/ChestIcon"; @@ -214,6 +215,8 @@ export const CONTENT_KIND_ICONS: Record = { comments: MessageSquareMore, reposts: Repeat2, "generic-reposts": Repeat2, + reactions: SmilePlus, + zaps: Zap, voice: Mic, "custom-emojis": Smile, statuses: SmilePlus, diff --git a/src/pages/FollowPage.tsx b/src/pages/FollowPage.tsx index e6ada370..118ae5a7 100644 --- a/src/pages/FollowPage.tsx +++ b/src/pages/FollowPage.tsx @@ -155,6 +155,7 @@ function ProfileFeed({ pubkey }: { pubkey: string }) { key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id} event={item.event} repostedBy={item.repostedBy} + repostEvent={item.repostEvent} compact /> ))} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 1501ffe0..a80aed71 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -2645,6 +2645,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id} event={item.event} repostedBy={item.repostedBy} + repostEvent={item.repostEvent} /> ))} @@ -3151,6 +3152,7 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: { key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id} event={item.event} repostedBy={item.repostedBy} + repostEvent={item.repostEvent} /> ))} diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index fad83e12..56551c86 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -805,7 +805,7 @@ export function SearchPage() { if (isRepostKind(event.kind)) { const embedded = parseRepostContent(event); if (embedded) { - return ; + return ; } return null; } diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index ff3aa5f3..8c170b14 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -38,6 +38,8 @@ export function TestApp({ children }: TestAppProps) { feedIncludeComments: true, feedIncludeReposts: true, feedIncludeGenericReposts: true, + feedIncludeReactions: false, + feedIncludeZaps: false, feedIncludeArticles: false, showArticles: false, showHighlights: false,