+ {boosters.length > 0 ? (
+
+ {t('Boosted by:')}
+
+
+ ) : null}
+ {plusLikers.length > 0 ? (
+
+ {t('Liked by:')}
+
+ {DEFAULT_LIKE_REACTION_DISPLAY_EMOJI}
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index f1b352aa..38056e3c 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -17,12 +17,17 @@ import {
getRootATag,
getRootETag,
isNip56ReportEvent,
+ isMentioningMutedUsers,
+ isNip18RepostKind,
+ isNip25ReactionKind,
isReplaceableEvent,
kind1QuotesThreadRoot,
resolveDeclaredThreadRootEventHex
} from '@/lib/event'
import logger from '@/lib/logger'
import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
+import { isDefaultPlusLikeReactionContent } from '@/lib/like-reaction-emojis'
+import { muteSetHas } from '@/lib/mute-set'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
@@ -64,11 +69,19 @@ import { useTranslation } from 'react-i18next'
import { useQuoteEvents } from '@/hooks'
import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
+import ThreadContextRootNote from './ThreadContextRootNote'
+import ThreadLowEffortStrip from './ThreadLowEffortStrip'
import ThreadQuoteBacklink, {
BacklinkAvatarStrip,
ThreadQuoteBacklinkSkeleton
} from './ThreadQuoteBacklink'
+/** Collapse default `+` likes into {@link ThreadLowEffortStrip}; keep discussion ⬆️/⬇️ vote rows. */
+function isDefaultPlusLikeReactionEvent(evt: NEvent, isDiscussionRoot: boolean): boolean {
+ if (isDiscussionRoot) return false
+ return isNip25ReactionKind(evt.kind) && isDefaultPlusLikeReactionContent(evt.content)
+}
+
type TRootInfo =
| { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string }
@@ -1026,6 +1039,22 @@ function ReplyNoteList({
(evt: NEvent) => {
if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return
+ if (isNip18RepostKind(evt.kind)) {
+ if (
+ rootInfo &&
+ replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) &&
+ !muteSetHas(mutePubkeySet, evt.pubkey) &&
+ !(
+ hideContentMentioningMutedUsers === true &&
+ isMentioningMutedUsers(evt, mutePubkeySet)
+ )
+ ) {
+ noteStatsService.updateNoteStatsByEvents([evt], event.pubkey, {
+ statsRootEvent: event
+ })
+ }
+ return
+ }
if (
shouldHideThreadResponseEvent(
evt,
@@ -1606,6 +1635,7 @@ function ReplyNoteList({
(item: NEvent) => {
if (isPollVoteKind(item)) return false
if (isZapPollThreadZapReceipt(item, event)) return false
+ if (isDefaultPlusLikeReactionEvent(item, isDiscussionRoot)) return false
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) {
return false
}
@@ -1633,13 +1663,27 @@ function ReplyNoteList({
isUserTrusted,
rootInfo?.type,
repliesMap,
- event
+ event,
+ isDiscussionRoot
]
)
+ const threadStatsNoteId = useMemo(() => {
+ if (rootInfo?.type === 'E') return rootInfo.id
+ if (rootInfo?.type === 'A' && /^[0-9a-f]{64}$/i.test(rootInfo.eventId)) {
+ return rootInfo.eventId.toLowerCase()
+ }
+ return event.id
+ }, [rootInfo, event.id])
+
+ const showThreadContextRoot =
+ rootInfo?.type === 'E' &&
+ /^[0-9a-f]{64}$/i.test(rootInfo.id) &&
+ rootInfo.id.toLowerCase() !== event.id.toLowerCase()
+
const visibleForRender = useMemo(
- () => visibleFeed.filter(shouldShowFeedItem),
- [visibleFeed, shouldShowFeedItem]
+ () => visibleFeed.filter((e) => shouldShowFeedItem(e) && e.id !== event.id),
+ [visibleFeed, shouldShowFeedItem, event.id]
)
const displayRows = useMemo(
@@ -1660,6 +1704,9 @@ function ReplyNoteList({
)}
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 48bfde4a..9573581b 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -36,6 +36,8 @@ export default {
followings: "followings",
boosted: "boosted",
"Boosted by:": "Boosted by:",
+ "Liked by:": "Liked by:",
+ "Original post": "Original post",
"just now": "just now",
"n minutes ago": "{{n}} minutes ago",
"n m": "{{n}}m",
diff --git a/src/lib/like-reaction-emojis.ts b/src/lib/like-reaction-emojis.ts
index 553ace3c..c477d2dc 100644
--- a/src/lib/like-reaction-emojis.ts
+++ b/src/lib/like-reaction-emojis.ts
@@ -1,3 +1,5 @@
+import type { TEmoji } from '@/types'
+
/**
* Single source for the quick-like emoji row used by the EmojiPicker / LikeButton.
* EmojiPicker re-exports this list as EMOJI_PICKER_REACTIONS for LikeButton.
@@ -21,3 +23,14 @@ export const DEFAULT_SUGGESTED_EMOJIS = [
'🫂',
'🚀'
] as const
+
+/** Kind-7 content (or stats row emoji) for the default quick-like (`+`). */
+export function isDefaultPlusLikeReactionEmoji(emoji: TEmoji | string): boolean {
+ if (typeof emoji !== 'string') return false
+ const c = emoji.trim()
+ return c === '' || c === DEFAULT_LIKE_REACTION_CONTENT
+}
+
+export function isDefaultPlusLikeReactionContent(content: string): boolean {
+ return isDefaultPlusLikeReactionEmoji(content)
+}
diff --git a/src/lib/thread-response-filter.test.ts b/src/lib/thread-response-filter.test.ts
new file mode 100644
index 00000000..cf02af53
--- /dev/null
+++ b/src/lib/thread-response-filter.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { kinds } from 'nostr-tools'
+import type { Event } from 'nostr-tools'
+import { ExtendedKind } from '@/constants'
+import { isThreadBoosterOnlyRow, shouldHideThreadResponseEvent } from './thread-response-filter'
+
+function baseEvent(overrides: Partial
= {}): Event {
+ return {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1000,
+ kind: kinds.ShortTextNote,
+ tags: [],
+ content: 'hello',
+ sig: 'd'.repeat(128),
+ ...overrides
+ }
+}
+
+describe('thread response filter', () => {
+ it('treats NIP-18 reposts as booster-only rows', () => {
+ const repost = baseEvent({
+ kind: kinds.Repost,
+ tags: [['e', 'c'.repeat(64)]],
+ content: ''
+ })
+ expect(isThreadBoosterOnlyRow(repost)).toBe(true)
+ expect(shouldHideThreadResponseEvent(repost, new Set(), false)).toBe(true)
+ })
+
+ it('does not treat kind-1 rows as booster-only (only kinds 6 and 16)', () => {
+ const target = baseEvent({ content: 'boosted note' })
+ expect(isThreadBoosterOnlyRow(baseEvent({ content: JSON.stringify(target) }))).toBe(false)
+ expect(
+ isThreadBoosterOnlyRow(
+ baseEvent({ content: `My take.\n\n${JSON.stringify(target)}` })
+ )
+ ).toBe(false)
+ })
+
+ it('hides generic repost kind 16', () => {
+ const repost = baseEvent({
+ kind: ExtendedKind.GENERIC_REPOST,
+ tags: [['e', 'c'.repeat(64)]]
+ })
+ expect(isThreadBoosterOnlyRow(repost)).toBe(true)
+ })
+})
diff --git a/src/lib/thread-response-filter.ts b/src/lib/thread-response-filter.ts
index dcb6adbb..fd2d0f82 100644
--- a/src/lib/thread-response-filter.ts
+++ b/src/lib/thread-response-filter.ts
@@ -1,4 +1,4 @@
-import { isMentioningMutedUsers } from '@/lib/event'
+import { isMentioningMutedUsers, isNip18RepostKind } from '@/lib/event'
import { muteSetHas } from '@/lib/mute-set'
import { normalizeUrl } from '@/lib/url'
import type { Event } from 'nostr-tools'
@@ -13,12 +13,21 @@ export function buildNormalizedBlockedRelaySet(blockedRelays: readonly string[]
return s
}
-/** Hide thread replies / backlinks: muted author or (when enabled) mentions of mutes. */
+/**
+ * NIP-18 boosts: kind **6** (repost kind-1) and kind **16** (generic repost). Shown on the OP
+ * booster strip only — never as discussion thread rows.
+ */
+export function isThreadBoosterOnlyRow(evt: Event): boolean {
+ return isNip18RepostKind(evt.kind)
+}
+
+/** Hide thread replies / backlinks: boosts, wire-format JSON blobs, muted author, or mute mentions. */
export function shouldHideThreadResponseEvent(
evt: Event,
mutePubkeySet: Set,
hideContentMentioningMutedUsers: boolean | undefined
): boolean {
+ if (isThreadBoosterOnlyRow(evt)) return true
if (muteSetHas(mutePubkeySet, evt.pubkey)) return true
if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true
return false
diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx
index 5b126389..85f92f9a 100644
--- a/src/pages/secondary/NotePage/index.tsx
+++ b/src/pages/secondary/NotePage/index.tsx
@@ -5,7 +5,6 @@ import { ExtendedKind } from '@/constants'
import ContentPreview from '@/components/ContentPreview'
import client from '@/services/client.service'
import Note from '@/components/Note'
-import NoteBoostBadges from '@/components/NoteBoostBadges'
import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats'
import UserAvatar from '@/components/UserAvatar'
@@ -507,24 +506,29 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
>
{rootITag &&
}
- {rootEventId &&
- !eventPointersReferenceSameNote(rootEventId, parentEventId) && (
-
- )}
- {parentEventId && (
+ {rootEventId && (
)}
+ {parentEventId &&
+ !eventPointersReferenceSameNote(parentEventId, rootEventId) &&
+ !eventPointersReferenceSameNote(parentEventId, finalEvent.id) && (
+
+ )}
-
()
replies.forEach((reply) => {
if (newReplyIdSet.has(reply.id)) return
+ // NIP-18 kind 6 / 16 — stats + OP booster strip only, not thread reply map keys.
+ if (isNip18RepostKind(reply.kind)) {
+ client.addEventToCache(reply)
+ return
+ }
if (isNip25ReactionKind(reply.kind)) {
newReplyIdSet.add(reply.id)
client.addEventToCache(reply)
diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts
index bfd6ce08..227f6651 100644
--- a/src/services/client-events.service.ts
+++ b/src/services/client-events.service.ts
@@ -16,6 +16,7 @@ import {
getReplaceableCoordinateFromEvent,
getRootATag,
getRootETag,
+ isNip18RepostKind,
isNip25ReactionKind,
isReplyNoteEvent,
isReplaceableEvent,
@@ -969,7 +970,7 @@ export class EventService {
const qref = getQuotedReferenceFromQTags(ev)
add(qref?.hexId)
add(qref?.coordinate)
- if (ev.kind === kinds.Zap || ev.kind === kinds.Repost || ev.kind === ExtendedKind.GENERIC_REPOST) {
+ if (ev.kind === kinds.Zap || isNip18RepostKind(ev.kind)) {
add(getFirstHexEventIdFromETags(ev.tags))
}
if (
@@ -998,6 +999,7 @@ export class EventService {
let added = 0
for (const [, ev] of this.sessionEventCache.entries()) {
if (shouldDropEventOnIngest(ev)) continue
+ if (isNip18RepostKind(ev.kind)) continue
const threadishKind1Quote =
(root.type === 'E' || root.type === 'A') && kind1QuotesThreadRoot(ev, root)
if (!isReplyNoteEvent(ev) && !threadishKind1Quote && !isNip25ReactionKind(ev.kind))
diff --git a/src/services/discussion-feed-cache.service.ts b/src/services/discussion-feed-cache.service.ts
index 75ad0a44..4e2fbe08 100644
--- a/src/services/discussion-feed-cache.service.ts
+++ b/src/services/discussion-feed-cache.service.ts
@@ -1,4 +1,5 @@
import { isNip25ReactionKind } from '@/lib/event'
+import { isThreadBoosterOnlyRow } from '@/lib/thread-response-filter'
import { Event as NEvent } from 'nostr-tools'
import logger from '@/lib/logger'
@@ -99,7 +100,7 @@ class DiscussionFeedCacheService {
logger.debug('[DiscussionFeedCache] Cache hit (fresh) for thread:', cacheKey, 'replies:', cachedData.replies.length)
}
- return cachedData.replies.filter((r) => !isNip25ReactionKind(r.kind))
+ return cachedData.replies.filter((r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r))
}
/**
@@ -140,12 +141,12 @@ class DiscussionFeedCacheService {
const existingReplyIds = new Set(existingData.replies.map(r => r.id))
const newReplies = replies.filter(r => !existingReplyIds.has(r.id))
mergedReplies = [...existingData.replies, ...newReplies].filter(
- (r) => !isNip25ReactionKind(r.kind)
+ (r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r)
)
logger.debug('[DiscussionFeedCache] Merged replies for thread:', cacheKey, 'existing:', existingData.replies.length, 'new:', newReplies.length, 'total:', mergedReplies.length)
} else {
// No existing cache or rootInfo mismatch, use new replies
- mergedReplies = replies.filter((r) => !isNip25ReactionKind(r.kind))
+ mergedReplies = replies.filter((r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r))
logger.debug('[DiscussionFeedCache] Cached new replies for thread:', cacheKey, 'replies:', replies.length)
}