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:
@@ -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
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user