Add feed toggles for reactions and zaps, rendered as overlays on the target post

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.
This commit is contained in:
Alex Gleason
2026-05-13 15:33:06 -05:00
parent 2ede59d2db
commit 4138e12d5e
15 changed files with 390 additions and 148 deletions
+2
View File
@@ -58,6 +58,8 @@ const hardcodedConfig: AppConfig = {
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeReactions: false,
feedIncludeZaps: false,
feedIncludeArticles: true,
showArticles: true,
showHighlights: true,
+11 -5
View File
@@ -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
<div>
{feedItems.map((item: FeedItem) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
key={feedItemKey(item)}
event={item.event}
repostedBy={item.repostedBy}
repostEvent={item.repostEvent}
reactedBy={item.reactedBy}
zappedBy={item.zappedBy}
/>
))}
{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 }) {
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
key={feedItemKey(item)}
event={item.event}
repostedBy={item.repostedBy}
repostEvent={item.repostEvent}
reactedBy={item.reactedBy}
zappedBy={item.zappedBy}
/>
))}
{hasNextPage && (
+107 -25
View File
@@ -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 ? (
<EventActionHeader
pubkey={reactedBy.pubkey}
icon={SmilePlus}
iconNode={
<span className="size-4 translate-y-px flex items-center justify-center leading-none">
<ReactionEmoji
content={reactedBy.event.content}
tags={reactedBy.event.tags}
className="inline-block h-4 w-4 object-contain"
/>
</span>
}
action="reacted to"
actionEvent={reactedBy.event}
/>
) : zappedBy ? (
<EventActionHeader
pubkey={zappedBy.pubkey}
icon={zappedBy.event.kind === 8333 ? Bitcoin : Zap}
iconClassName="text-amber-500"
action="zapped"
actionEvent={zappedBy.event}
extra={zappedBy.sats > 0 ? (
<span className="font-semibold text-amber-500">
{formatNumber(zappedBy.sats)} {zappedBy.sats === 1 ? 'sat' : 'sats'}
</span>
) : undefined}
/>
) : repostedBy ? (
<EventActionHeader
pubkey={repostedBy}
icon={RepostIcon}
iconClassName="text-accent"
action="reposted"
actionEvent={repostEvent}
/>
) : undefined;
@@ -925,7 +965,7 @@ export const NoteCard = memo(function NoteCard({
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
header={repostHeader}
header={wrapperHeader}
icon={
<div className={cn("flex items-center justify-center rounded-full bg-pink-500/10 shrink-0 text-lg leading-none", iconSize)}>
<ReactionEmoji content={event.content} tags={event.tags} className="h-5 w-5 object-contain" />
@@ -946,7 +986,7 @@ export const NoteCard = memo(function NoteCard({
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
header={repostHeader}
header={wrapperHeader}
icon={
<div className={cn("flex items-center justify-center rounded-full bg-accent/10 shrink-0", iconSize)}>
<RepostIcon className="size-5 text-accent" />
@@ -969,7 +1009,7 @@ export const NoteCard = memo(function NoteCard({
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
header={repostHeader}
header={wrapperHeader}
icon={
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
<Zap className="size-5 text-amber-500 fill-amber-500" />
@@ -998,7 +1038,7 @@ export const NoteCard = memo(function NoteCard({
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
header={repostHeader}
header={wrapperHeader}
icon={
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => 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}
<div className="flex gap-3">
<div className="flex flex-col items-center">
{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 ? (
<EventActionHeader
pubkey={repostedBy}
icon={RepostIcon}
iconClassName="text-accent"
action="reposted"
/>
{/* 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 (`<ReactionEmoji>`) 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<number, KindHeaderConfig> = {
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 (
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3 min-w-0">
<div className="w-11 shrink-0 flex justify-end">
<Icon
className={cn(
"size-4 translate-y-px",
iconClassName ?? "text-primary",
)}
/>
{iconNode ?? (
<Icon
className={cn(
"size-4 translate-y-px",
iconClassName ?? "text-primary",
)}
/>
)}
</div>
<div className="flex items-center min-w-0">
{author.isLoading ? (
@@ -1962,7 +2034,17 @@ export function EventActionHeader({
</ProfileHoverCard>
)}
<span className={cn("shrink-0", author.isLoading && "ml-1")}>
{action}
{actionHref ? (
<Link
to={actionHref}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{verbContent}
</Link>
) : (
verbContent
)}
{noun && nounRoute && (
<>
{" "}
@@ -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 && (
+4
View File
@@ -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 */
+172 -110
View File
@@ -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<FeedItem[]> {
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<string, PendingWrapper[]>();
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<string, NostrEvent>();
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<string, FeedItem>();
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<string, NostrEvent>();
// 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<string, FeedItem>();
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<string, NostrEvent>();
// 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<string, FeedItem>();
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<string, unknown> = { 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.
+4 -4
View File
@@ -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 {
+23
View File
@@ -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',
+55 -3
View File
@@ -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']);
+2
View File
@@ -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(),
+3
View File
@@ -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<string, IconComponent> = {
comments: MessageSquareMore,
reposts: Repeat2,
"generic-reposts": Repeat2,
reactions: SmilePlus,
zaps: Zap,
voice: Mic,
"custom-emojis": Smile,
statuses: SmilePlus,
+1
View File
@@ -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
/>
))}
+2
View File
@@ -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}
/>
))}
+1 -1
View File
@@ -805,7 +805,7 @@ export function SearchPage() {
if (isRepostKind(event.kind)) {
const embedded = parseRepostContent(event);
if (embedded) {
return <NoteCard key={event.id} event={embedded} repostedBy={event.pubkey} highlight={isNew} />;
return <NoteCard key={event.id} event={embedded} repostedBy={event.pubkey} repostEvent={event} highlight={isNew} />;
}
return null;
}
+2
View File
@@ -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,