diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 3194c49..528611c 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -51,7 +51,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className? } return ( -
+
e.stopPropagation()}> )} - {/* Hashtags */} - {parsedContent?.hashtags?.length > 0 && ( -
-

Tags:

-
- {parsedContent?.hashtags?.map((tag) => ( -
{ - e.stopPropagation() - push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) - }} - > - #{tag} -
- ))} + {/* Hashtags - only show t-tags that don't appear as #hashtag in content */} + {(() => { + // Get content hashtags from parsedContent (hashtags extracted from content as #hashtag) + // Normalize to lowercase for comparison + const contentHashtags = new Set((parsedContent?.hashtags || []).map(t => t.toLowerCase())) + // Filter metadata.tags (t-tags from event) to exclude those already in content + const tagsToShow = (metadata.tags || []).filter(tag => !contentHashtags.has(tag.toLowerCase())) + return tagsToShow.length > 0 && ( +
+

Tags:

+
+ {tagsToShow.map((tag) => ( +
{ + e.stopPropagation() + push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) + }} + > + #{tag} +
+ ))} +
-
- )} + ) + })()} )} diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index d58e387..31d8b19 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -37,6 +37,18 @@ export default function MarkdownArticle({ const allImages = useMemo(() => extractAllImagesFromEvent(event), [event]) const contentRef = useRef(null) + // Extract hashtags that are actually present in the content (as literal #hashtag) + // This ensures we only render green links for hashtags that are in the content, not from t-tags + const contentHashtags = useMemo(() => { + const hashtags = new Set() + const hashtagRegex = /#(\w+)/g + let match + while ((match = hashtagRegex.exec(event.content)) !== null) { + hashtags.add(match[1].toLowerCase()) + } + return hashtags + }, [event.content]) + // Extract, normalize, and deduplicate all media URLs (images, audio, video) // from content, imeta tags, and image tags const mediaUrls = useMemo(() => { @@ -158,8 +170,20 @@ export default function MarkdownArticle({ // Handle hashtag links (format: /notes?t=tag) if (href.startsWith('/notes?t=') || href.startsWith('notes?t=')) { + // Extract the hashtag from the href + const hashtagMatch = href.match(/[?=]([^&]+)/) + const hashtag = hashtagMatch ? hashtagMatch[1].toLowerCase() : '' + + // Only render as green link if this hashtag is actually in the content + // If not in content, suppress the link and render as plain text (hashtags are handled by split-based approach) + if (!contentHashtags.has(hashtag)) { + // Hashtag not in content, render as plain text (not a link at all) + return {children} + } + // Normalize href to include leading slash if missing const normalizedHref = href.startsWith('/') ? href : `/${href}` + // Inline hashtags from content should always be green return ( {children} } - // Handle hashtags and wikilinks - const hashtagRegex = /#(\w+)/g + // Don't process hashtags in text component - they're already handled by split-based approach + // Only handle wikilinks here const wikilinkRegex = /\[\[([^\]]+)\]\]/g - const allMatches: Array<{index: number, end: number, type: 'hashtag' | 'wikilink', data: any}> = [] + const allMatches: Array<{index: number, end: number, type: 'wikilink', data: any}> = [] let match - while ((match = hashtagRegex.exec(children)) !== null) { - allMatches.push({ - index: match.index, - end: match.index + match[0].length, - type: 'hashtag', - data: match[1] - }) - } - while ((match = wikilinkRegex.exec(children)) !== null) { const content = match[1] let target = content.includes('|') ? content.split('|')[0].trim() : content.trim() @@ -308,15 +323,7 @@ export default function MarkdownArticle({ parts.push(children.slice(lastIndex, match.index)) } - if (match.type === 'hashtag') { - parts.push( - - #{match.data} - - ) - } else { - parts.push() - } + parts.push() lastIndex = match.end } @@ -344,7 +351,7 @@ export default function MarkdownArticle({ ) } }) as Components, - [showImageGallery, event.pubkey, mediaUrls, event.kind] + [showImageGallery, event.pubkey, mediaUrls, event.kind, contentHashtags] ) return ( @@ -439,6 +446,13 @@ export default function MarkdownArticle({ // Check if this part is a hashtag if (part.match(/^#\w+$/)) { const hashtag = part.slice(1) + const normalizedHashtag = hashtag.toLowerCase() + + // Only render as green link if this hashtag is actually in the content + if (!contentHashtags.has(normalizedHashtag)) { + // Hashtag not in content, render as plain text + return {part} + } // Add spaces before and after unless at start/end of line const isStartOfLine = index === 0 || array[index - 1].match(/^[\s]*$/) !== null @@ -447,16 +461,17 @@ export default function MarkdownArticle({ const beforeSpace = isStartOfLine ? '' : ' ' const afterSpace = isEndOfLine ? '' : ' ' + // Inline hashtags from content should always be green return ( {beforeSpace && beforeSpace} { e.preventDefault() e.stopPropagation() - const url = `/notes?t=${hashtag.toLowerCase()}` + const url = `/notes?t=${normalizedHashtag}` console.log('[MarkdownArticle] Clicking hashtag, navigating to:', url) push(url) }} @@ -519,21 +534,23 @@ export default function MarkdownArticle({ )} - {metadata.tags.length > 0 && ( -
- {metadata.tags.map((tag) => ( -
{ - e.stopPropagation() - push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) - }} - > - #{tag} -
- ))} + {metadata.tags.filter(tag => !contentHashtags.has(tag.toLowerCase())).length > 0 && ( +
+ {metadata.tags + .filter(tag => !contentHashtags.has(tag.toLowerCase())) + .map((tag) => ( +
{ + e.stopPropagation() + push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) + }} + > + #{tag} +
+ ))}
)}
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index b700cf0..148af4d 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -180,7 +180,7 @@ export default function Note({ onClick={(e) => { // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement - if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]')) { + if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]')) { return } navigateToNote(toNote(event)) diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index a0e8eb9..be6678a 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -26,8 +26,20 @@ export default function MainNoteCard({
{ + // Don't navigate if clicking on interactive elements + const target = e.target as HTMLElement + if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-parent-note-preview]')) { + return + } + // For embedded notes, allow clicks (don't exclude [data-embedded-note]) + // as embedded notes should be clickable to navigate to their page + if (!embedded && target.closest('[data-embedded-note]')) { + return + } e.stopPropagation() - navigateToNote(toNote(originalNoteId ?? event)) + // Ensure navigation happens immediately + const noteUrl = toNote(originalNoteId ?? event) + navigateToNote(noteUrl) }} >
diff --git a/src/components/ParentNotePreview/index.tsx b/src/components/ParentNotePreview/index.tsx index fb50bbe..d467199 100644 --- a/src/components/ParentNotePreview/index.tsx +++ b/src/components/ParentNotePreview/index.tsx @@ -20,6 +20,7 @@ export default function ParentNotePreview({ if (isFetching) { return (
) - }, []) + }, [defaultContent, parentEvent, openFrom, setOpen]) if (isSmallScreen) { return ( diff --git a/src/components/RelayInfo/RelayReviewCard.tsx b/src/components/RelayInfo/RelayReviewCard.tsx index 0b1dd67..2ba837d 100644 --- a/src/components/RelayInfo/RelayReviewCard.tsx +++ b/src/components/RelayInfo/RelayReviewCard.tsx @@ -26,7 +26,14 @@ export default function RelayReviewCard({ return (
navigateToNote(toNote(event))} + onClick={(e) => { + // Don't navigate if clicking on interactive elements + const target = e.target as HTMLElement + if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]')) { + return + } + navigateToNote(toNote(event)) + }} >
diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 8d90ebc..397de27 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -25,11 +25,13 @@ export default function ReplyNote({ event, parentEventId, onClickParent = () => {}, + onClickReply, highlight = false }: { event: Event parentEventId?: string onClickParent?: () => void + onClickReply?: (event: Event) => void highlight?: boolean }) { const { t } = useTranslation() @@ -58,10 +60,14 @@ export default function ReplyNote({ onClick={(e) => { // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement - if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) { + if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-parent-note-preview]')) { return } - navigateToNote(toNote(event)) + if (onClickReply) { + onClickReply(event) + } else { + navigateToNote(toNote(event)) + } }} > diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 472d235..93502d9 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -380,7 +380,10 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even if (scrollTo) { const ref = replyRefs.current[eventId] if (ref) { - ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + // Use setTimeout to ensure DOM is updated before scrolling + setTimeout(() => { + ref.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, 0) } } setHighlightReplyId(eventId) @@ -416,6 +419,15 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even const parentETag = getParentETag(reply) const parentEventHexId = parentETag?.[1] const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined + + // Check if this reply belongs to the same thread as the root event + const replyRootId = getRootEventHexId(reply) + const belongsToSameThread = rootInfo && ( + (rootInfo.type === 'E' && replyRootId === rootInfo.id) || + (rootInfo.type === 'A' && getRootATag(reply)?.[1] === rootInfo.id) || + (rootInfo.type === 'I' && reply.tags.find(tagNameEquals('I'))?.[1] === rootInfo.id) + ) + return (
(replyRefs.current[reply.id] = el)} @@ -433,6 +445,22 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even } highlightReply(parentEventHexId) }} + onClickReply={belongsToSameThread ? (replyEvent) => { + // Update URL without full navigation + const replyNoteUrl = toNote(replyEvent.id) + window.history.pushState(null, '', replyNoteUrl) + + // Ensure the reply is visible by expanding the list if needed + const replyIndex = replies.findIndex(r => r.id === replyEvent.id) + if (replyIndex >= 0 && replyIndex >= showCount) { + setShowCount(replyIndex + 1) + } + + // Highlight and scroll to the reply (use setTimeout to ensure DOM is updated) + setTimeout(() => { + highlightReply(replyEvent.id, true) + }, 50) + } : undefined} highlight={highlightReplyId === reply.id} />
diff --git a/src/lib/nostr-parser.tsx b/src/lib/nostr-parser.tsx index d20d5f0..41b6a7a 100644 --- a/src/lib/nostr-parser.tsx +++ b/src/lib/nostr-parser.tsx @@ -451,11 +451,13 @@ export function renderNostrContent(parsedContent: ParsedNostrContent, className? if (element.type === 'hashtag' && element.hashtag) { const normalizedHashtag = element.hashtag.toLowerCase() + // Only render as green link if this hashtag was parsed from the content + // (parseNostrContent already only extracts hashtags from content, not t-tags) return (
#{element.hashtag} diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index a005004..ba6121a 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -278,6 +278,15 @@ class RelaySelectionService { selectedRelays = Array.from(new Set(selectedRelays)) } + // ALWAYS include cache relays (local network relays) in selected relays + // Cache relays are important for offline functionality + const cacheRelays = userWriteRelays.filter(url => isLocalNetworkUrl(url)) + if (cacheRelays.length > 0) { + selectedRelays = [...selectedRelays, ...cacheRelays] + // Deduplicate after adding cache relays + selectedRelays = Array.from(new Set(selectedRelays)) + } + // Filter out blocked relays return this.filterBlockedRelays(selectedRelays, context.blockedRelays) }