@@ -166,9 +171,19 @@ export default function ReplyNote({
) : null}
{show ? (
isNip25ReactionKind(event.kind) ? (
-
+
{reactionDisplay.status === 'pending' ? (
-
+
) : reactionDisplay.status === 'vote_up' ? (
{DISCUSSION_UPVOTE_DISPLAY}
@@ -180,7 +195,9 @@ export default function ReplyNote({
) : (
)}
- {t(notificationReactionSummaryKey(reactionDisplay))}
+ {reactionDisplay.status !== 'default' && (
+ {t(notificationReactionSummaryKey(reactionDisplay))}
+ )}
) : event.kind === kinds.Zap ? (
@@ -208,15 +225,17 @@ export default function ReplyNote({
- {show && !isNip25ReactionKind(event.kind) && (
+ {show && (
<>
-
+ {!isNip25ReactionKind(event.kind) && (
+
+ )}
>
)}
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index badf4e2b..8723b3ff 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -16,7 +16,6 @@ import {
getReplaceableCoordinateFromEvent,
getRootATag,
getRootETag,
- isNip25ReactionKind,
isNip56ReportEvent,
isReplaceableEvent,
kind1QuotesThreadRoot,
@@ -224,8 +223,8 @@ function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] {
/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link THREAD_BACKLINK_STREAM_KINDS}. */
const EA_THREAD_TAIL_REFERENCE_KINDS = new Set
(THREAD_BACKLINK_STREAM_KINDS)
-/** Web (NIP-22) thread: tail = reference-style rows + URL-scoped reactions (same block order as E/A). */
-const WEB_THREAD_EXTRA_TAIL_KINDS = new Set([kinds.Reaction, ExtendedKind.EXTERNAL_REACTION])
+/** Web (NIP-22) thread: tail = reference-style rows + URL-only external reactions (kind-7 stays in the chronological middle with other replies). */
+const WEB_THREAD_EXTRA_TAIL_KINDS = new Set([ExtendedKind.EXTERNAL_REACTION])
function isWebThreadTailKind(kind: number): boolean {
return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) || WEB_THREAD_EXTRA_TAIL_KINDS.has(kind)
@@ -262,8 +261,8 @@ function noteReactionEtagEqualsHex(ev: NEvent, hexLower: string): boolean {
}
/**
- * Thread REQ historically omitted kind 7; {@link replyMatchesThreadForList} also drops reactions from the reply list.
- * Reactions still need to merge into {@link noteStatsService} for the root so the note header matches notifications.
+ * Thread REQ may still omit some kind-7 rows; merge reactions that tag the root hex so OP stats stay warm.
+ * Listed reactions under “Antworten” come from {@link ReplyNoteList} BFS + {@link replyMatchesThreadForList}.
*/
function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) {
if (rootInfo.type === 'E') {
@@ -449,7 +448,6 @@ function ReplyNoteList({
events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return
- if (isNip25ReactionKind(evt.kind)) return
if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return
if (
diff --git a/src/constants.ts b/src/constants.ts
index 54f3a7dc..8a01a071 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -416,6 +416,7 @@ export const FAST_READ_RELAY_URLS = [
'wss://thecitadel.nostr1.com',
'wss://aggr.nostr.land',
'wss://primus.nostr1.com',
+ 'wss://wheat.happytavern.co'
]
// Optimized relay list for write operations (no aggregator since it's read-only)
@@ -446,7 +447,8 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://nostr.einundzwanzig.space',
'wss://nostr-pub.wellorder.net',
'wss://pyramid.fiatjaf.com/',
- 'wss://nostrelites.org'
+ 'wss://nostrelites.org',
+ 'wss://wheat.happytavern.co'
]
export const PROFILE_RELAY_URLS = [
diff --git a/src/index.css b/src/index.css
index 1e990320..dcff5b69 100644
--- a/src/index.css
+++ b/src/index.css
@@ -62,6 +62,24 @@
pointer-events: none;
}
+ /*
+ * TipTap / ProseMirror composer: without a color-emoji font in the stack (common on Linux + Firefox),
+ * Unicode emoji pick a text-font glyph (thin/outline). Preview and published notes often look “correct”
+ * because markdown/prose or system fonts resolve emoji differently. Append emoji-capable families last.
+ */
+ .tiptap .ProseMirror {
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ sans-serif,
+ 'Apple Color Emoji',
+ 'Segoe UI Emoji',
+ 'Segoe UI Symbol',
+ 'Noto Color Emoji',
+ 'Noto Emoji',
+ sans-serif;
+ }
+
.scrollbar-hide {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 099a0c27..d2a078ba 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -14,6 +14,7 @@ import {
generateBech32IdFromETag,
getFirstHexEventIdFromETags,
getImetaInfoFromImetaTag,
+ getNip25ReactionTargetHexFromTags,
tagNameEquals
} from './tag'
@@ -175,8 +176,22 @@ export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set)
export function getParentETag(event?: Event) {
if (!event) return undefined
- // NIP-25 reactions, NIP-18 reposts (6 / 16), poll responses: first hex `e` / `E` references the target note.
- if (event.kind === kinds.Reaction || isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) {
+ // NIP-25 reactions: reacted-to id is often the `reply`-marked `e`, not the first `e` (root is commonly first).
+ if (event.kind === kinds.Reaction) {
+ const targetHex = getNip25ReactionTargetHexFromTags(event.tags)
+ if (!targetHex) return undefined
+ return (
+ event.tags.find(
+ (t) => t[0] === 'e' && typeof t[1] === 'string' && t[1].toLowerCase() === targetHex
+ ) ??
+ event.tags.find(
+ (t) => t[0] === 'E' && typeof t[1] === 'string' && t[1].toLowerCase() === targetHex
+ )
+ )
+ }
+
+ // NIP-18 reposts (6 / 16), poll responses: first hex `e` / `E` references the target note.
+ if (isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) {
const firstId = getFirstHexEventIdFromETags(event.tags)
if (!firstId) return undefined
return (
diff --git a/src/lib/like-reaction-emojis.ts b/src/lib/like-reaction-emojis.ts
index 8b84b69b..553ace3c 100644
--- a/src/lib/like-reaction-emojis.ts
+++ b/src/lib/like-reaction-emojis.ts
@@ -2,4 +2,22 @@
* Single source for the quick-like emoji row used by the EmojiPicker / LikeButton.
* EmojiPicker re-exports this list as EMOJI_PICKER_REACTIONS for LikeButton.
*/
-export const DEFAULT_SUGGESTED_EMOJIS = ['❤️', '👍', '🔥', '😂', '😢', '🫂', '🚀'] as const
+
+/** NIP-25 default positive reaction is the character `+`, not a Unicode heart. */
+export const DEFAULT_LIKE_REACTION_CONTENT = '+' as const
+
+/**
+ * Visual glyph for {@link DEFAULT_LIKE_REACTION_CONTENT} in UI (heart suit, emoji presentation).
+ * Published reaction content stays `+`.
+ */
+export const DEFAULT_LIKE_REACTION_DISPLAY_EMOJI = '\u2665\uFE0F'
+
+export const DEFAULT_SUGGESTED_EMOJIS = [
+ DEFAULT_LIKE_REACTION_CONTENT,
+ '👍',
+ '🔥',
+ '😂',
+ '😢',
+ '🫂',
+ '🚀'
+] as const
diff --git a/src/lib/reaction-display.ts b/src/lib/reaction-display.ts
index dbea88d9..1e85dab1 100644
--- a/src/lib/reaction-display.ts
+++ b/src/lib/reaction-display.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_LIKE_REACTION_CONTENT } from '@/lib/like-reaction-emojis'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { isNip25ReactionKind } from '@/lib/event'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
@@ -22,7 +23,7 @@ export function resolveReactionEmojiSync(event: Event, maxRawLength: number): TR
const raw = event.content?.trim() ?? ''
if (!raw) {
- return { mode: 'display', value: '❤️' }
+ return { mode: 'display', value: DEFAULT_LIKE_REACTION_CONTENT }
}
if (raw.length > maxRawLength) {
return { mode: 'display', value: `${raw.slice(0, maxRawLength)}…` }
diff --git a/src/lib/tag.ts b/src/lib/tag.ts
index 352e3612..233be5ce 100644
--- a/src/lib/tag.ts
+++ b/src/lib/tag.ts
@@ -50,6 +50,25 @@ export function getFirstHexEventIdFromETags(tags: string[][]): string | undefine
return undefined
}
+/**
+ * NIP-25 kind-7 target note id: prefer `e`/`E` with marker `reply` (reacted-to note in a thread).
+ * If several `e` tags have no markers, use the last hex id (common order: root, then reply).
+ */
+export function getNip25ReactionTargetHexFromTags(tags: string[][]): string | undefined {
+ const eRows: { id: string; marker?: string }[] = []
+ for (const t of tags) {
+ if (t[0] !== 'e' && t[0] !== 'E') continue
+ const id = t[1]
+ if (!id || !NOTE_HEX_ID_RE.test(id)) continue
+ const marker = typeof t[3] === 'string' ? t[3].toLowerCase() : undefined
+ if (marker === 'reply') return id.toLowerCase()
+ eRows.push({ id: id.toLowerCase(), marker })
+ }
+ if (eRows.length === 1) return eRows[0].id
+ if (eRows.length > 1) return eRows[eRows.length - 1].id
+ return undefined
+}
+
export function generateBech32IdFromETag(tag: string[]) {
try {
const [, id, relay, markerOrPubkey, pubkey] = tag
diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx
index 21d414b7..8f6084d9 100644
--- a/src/pages/secondary/NotePage/index.tsx
+++ b/src/pages/secondary/NotePage/index.tsx
@@ -546,7 +546,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
className="mt-3"
event={finalEvent}
fetchIfNotExisting
- displayTopZapsAndLikes
foregroundStats
/>
diff --git a/src/pages/secondary/RssArticlePage/index.tsx b/src/pages/secondary/RssArticlePage/index.tsx
index 902c2e0a..f572b638 100644
--- a/src/pages/secondary/RssArticlePage/index.tsx
+++ b/src/pages/secondary/RssArticlePage/index.tsx
@@ -303,7 +303,6 @@ const RssArticlePage = forwardRef(
className="mt-2"
event={syntheticRoot}
fetchIfNotExisting
- displayTopZapsAndLikes
foregroundStats
/>