Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
80592c8eb5
  1. 16
      src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx
  2. 609
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 180
      src/components/ProfileAbout/index.tsx
  4. 8
      src/components/WebPreview/index.tsx
  5. 41
      src/pages/primary/NoteListPage/FollowingFeed.tsx
  6. 3
      src/pages/secondary/MuteListPage/index.tsx
  7. 20
      src/providers/FavoriteRelaysActivityProvider.tsx

16
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import UserAvatar from '@/components/UserAvatar'
import ProfileAbout from '@/components/ProfileAbout'
import { Button } from '@/components/ui/button'
import {
Sheet,
@ -11,8 +12,7 @@ import { getProfileFromEvent } from '@/lib/event-metadata' @@ -11,8 +12,7 @@ import { getProfileFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { toProfile } from '@/lib/link'
import {
collectAggregatedNip05sFromKind0,
truncateAbout
collectAggregatedNip05sFromKind0
} from '@/lib/relay-pulse-nip05'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
@ -23,12 +23,9 @@ import { Users } from 'lucide-react' @@ -23,12 +23,9 @@ import { Users } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
const ABOUT_PREVIEW_LEN = 250
function CompactProfileCard({ event }: { event: Event }) {
const profile = getProfileFromEvent(event)
const nip05s = collectAggregatedNip05sFromKind0(event)
const about = truncateAbout(profile.about, ABOUT_PREVIEW_LEN)
const { setActiveNpubsDrawerOpen } = useFavoriteRelaysActivity()
const profileUrl = toProfile(event.pubkey)
const closeDrawer = () => setActiveNpubsDrawerOpen(false)
@ -45,11 +42,10 @@ function CompactProfileCard({ event }: { event: Event }) { @@ -45,11 +42,10 @@ function CompactProfileCard({ event }: { event: Event }) {
>
{profile.username}
</SecondaryPageLink>
{about ? (
<p className="mt-1 text-xs leading-snug text-muted-foreground whitespace-pre-wrap break-words">
{about}
</p>
) : null}
<ProfileAbout
about={profile.about}
className="mt-1 line-clamp-4 text-xs leading-snug text-muted-foreground break-words"
/>
{nip05s.length > 0 ? (
<ul className="mt-2 space-y-0.5 text-xs">
{nip05s.map((id) => (

609
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -15,7 +15,8 @@ import { @@ -15,7 +15,8 @@ import {
isVideo,
isAudio,
isWebsocketUrl,
isPseudoNostrHttpsUrl
isPseudoNostrHttpsUrl,
isSafeMediaUrl
} from '@/lib/url'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
@ -53,6 +54,38 @@ function truncateLinkText(text: string, maxLength: number = 200): string { @@ -53,6 +54,38 @@ function truncateLinkText(text: string, maxLength: number = 200): string {
return text.substring(0, maxLength) + '...'
}
/**
* Prevent invalid nested <a> trees by downgrading anchor descendants to spans.
*/
function stripNestedAnchors(node: React.ReactNode, keyPrefix: string): React.ReactNode {
if (node === null || node === undefined || typeof node === 'boolean') return node
if (Array.isArray(node)) {
return node.map((child, idx) => stripNestedAnchors(child, `${keyPrefix}-${idx}`))
}
if (!React.isValidElement(node)) return node
const element = node as React.ReactElement<{ children?: React.ReactNode }>
const children = element.props?.children
const sanitizedChildren =
children === undefined
? children
: React.Children.map(children, (child, idx) => stripNestedAnchors(child, `${keyPrefix}-${idx}`))
if (typeof element.type === 'string' && element.type.toLowerCase() === 'a') {
return (
<span key={(element.key as string) ?? `${keyPrefix}-anchor`} className="break-words">
{sanitizedChildren}
</span>
)
}
return React.cloneElement(element, undefined, sanitizedChildren)
}
function stripNestedAnchorsFromNodes(nodes: React.ReactNode[], keyPrefix: string): React.ReactNode[] {
return nodes.map((node, idx) => stripNestedAnchors(node, `${keyPrefix}-${idx}`))
}
/**
* Unescape JSON-encoded escape sequences in content
* Handles cases where content has been JSON-encoded multiple times or has escaped characters
@ -431,7 +464,8 @@ function normalizeSetextHeaders(content: string): string { @@ -431,7 +464,8 @@ function normalizeSetextHeaders(content: string): string {
* - wss:// and ws:// URLs -> hyperlinks to /relays/{url}
* Returns both rendered nodes and a set of hashtags found in content (for deduplication)
*/
export function parseMarkdownContent(
// Deprecated legacy parser kept only as a fallback reference during migration.
export function parseMarkdownContentLegacy(
content: string,
options: {
eventPubkey: string
@ -1859,10 +1893,24 @@ export function parseMarkdownContent( @@ -1859,10 +1893,24 @@ export function parseMarkdownContent(
}
} else if (pattern.type === 'markdown-link-standalone') {
const { url } = pattern.data
const cleanedStandalone = cleanUrl(url) || url
const cleanedStandalone = cleanUrl(url)
if (cleanedStandalone && (isVideo(cleanedStandalone) || isAudio(cleanedStandalone))) {
const poster = videoPosterMap?.get(cleanedStandalone)
parts.push(
<div key={`media-standalone-${patternIdx}`} className="my-2">
<MediaPlayer
src={cleanedStandalone}
className="max-w-[400px]"
mustLoad={false}
poster={poster}
/>
</div>
)
} else {
const cleanedStandaloneForPreview = cleanedStandalone || url
if (
suppressStandaloneWebPreviewCleanedUrls &&
suppressStandaloneWebPreviewCleanedUrls.has(cleanedStandalone)
suppressStandaloneWebPreviewCleanedUrls.has(cleanedStandaloneForPreview)
) {
parts.push(
<a
@ -1898,10 +1946,14 @@ export function parseMarkdownContent( @@ -1898,10 +1946,14 @@ export function parseMarkdownContent(
</div>
)
}
}
} else if (pattern.type === 'markdown-link') {
const { text, url } = pattern.data
// Process the link text for inline formatting (bold, italic, etc.)
const linkContent = parseInlineMarkdown(text, `link-${patternIdx}`, footnotes, emojiInfos)
const linkContent = stripNestedAnchorsFromNodes(
parseInlineMarkdown(text, `link-${patternIdx}`, footnotes, emojiInfos),
`link-${patternIdx}-sanitized`
)
// Markdown links should always be rendered as inline links, not block-level components
// This ensures they don't break up the content flow when used in paragraphs
if (isWebsocketUrl(url)) {
@ -2780,7 +2832,28 @@ function parseMarkdownContentMarked( @@ -2780,7 +2832,28 @@ function parseMarkdownContentMarked(
const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>()
const citations: Array<{ id: string; type: string; citationId: string }> = []
const blockTokens = marked.lexer(content, { gfm: true, breaks: true }) as any[]
const contentLines: string[] = []
let currentFootnoteId: string | null = null
for (const line of content.split('\n')) {
const footnoteDefMatch = line.match(/^\[\^([^\]]+)\]:\s+(.+)$/)
if (footnoteDefMatch) {
currentFootnoteId = footnoteDefMatch[1]
footnotes.set(currentFootnoteId, footnoteDefMatch[2])
continue
}
// Support indented continuation lines for multi-line footnote definitions.
if (currentFootnoteId && /^(?:\s{2,}|\t)(.+)$/.test(line)) {
const continuation = line.replace(/^(?:\s{2,}|\t)/, '')
const prev = footnotes.get(currentFootnoteId) ?? ''
footnotes.set(currentFootnoteId, prev ? `${prev} ${continuation}` : continuation)
continue
}
currentFootnoteId = null
contentLines.push(line)
}
const contentWithoutFootnotes = contentLines.join('\n')
const blockTokens = marked.lexer(contentWithoutFootnotes, { gfm: true, breaks: true }) as any[]
let codeBlockIdx = 0
const collectHashtags = (text: string) => {
@ -2834,9 +2907,9 @@ function parseMarkdownContentMarked( @@ -2834,9 +2907,9 @@ function parseMarkdownContentMarked(
break
case 'link': {
const href = String(token.href ?? '')
const children = renderInlineTokens(
token.tokens ?? [{ type: 'text', text: token.text ?? href }],
`${key}-link`
const children = stripNestedAnchorsFromNodes(
renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`),
`${key}-link-sanitized`
)
if (href.startsWith('payto://')) {
out.push(
@ -2885,6 +2958,15 @@ function parseMarkdownContentMarked( @@ -2885,6 +2958,15 @@ function parseMarkdownContentMarked(
)
break
}
if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) {
// Non-HTTP image tokens (e.g. npub...) must not be passed to image/media components.
out.push(
<span key={`${key}-img-fallback`} className="break-words">
{src}
</span>
)
break
}
const identifier = getImageIdentifier?.(cleaned)
const thumbnail =
imageThumbnailMap?.get(cleaned) ??
@ -2922,6 +3004,8 @@ function parseMarkdownContentMarked( @@ -2922,6 +3004,8 @@ function parseMarkdownContentMarked(
const renderParagraph = (token: any, key: string): React.ReactNode => {
const paragraphText = String(token.text ?? '').trim()
const isNostrEventBech32 = (value: string): boolean =>
value.startsWith('note') || value.startsWith('nevent') || value.startsWith('naddr')
const standaloneNostr = paragraphText.match(/^nostr:([a-z0-9]{8,})$/i)
if (standaloneNostr) {
const bech32Id = standaloneNostr[1]
@ -2977,12 +3061,18 @@ function parseMarkdownContentMarked( @@ -2977,12 +3061,18 @@ function parseMarkdownContentMarked(
}
// Mixed paragraphs can contain normal text plus one or more standalone nostr lines.
// Render event references as embedded cards even when they are not the entire paragraph.
// Render standalone special lines (nostr refs, relay links, plain URLs/media) as dedicated blocks
// even when they are not the entire paragraph.
const rawParagraphText = String(token.text ?? '')
if (rawParagraphText.includes('\n')) {
const lines = rawParagraphText.split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
const hasStandaloneNostrLine = lines.some((line) => /^nostr:([a-z0-9]{8,})$/i.test(line))
if (hasStandaloneNostrLine) {
const hasStandaloneSpecialLine = lines.some(
(line) =>
/^nostr:([a-z0-9]{8,})$/i.test(line) ||
/^wss?:\/\/\S+$/i.test(line) ||
/^https?:\/\/\S+$/i.test(line)
)
if (hasStandaloneSpecialLine) {
const lineNodes = lines.map((line, lineIdx) => {
const nostrMatch = line.match(/^nostr:([a-z0-9]{8,})$/i)
if (!nostrMatch) {
@ -3005,6 +3095,14 @@ function parseMarkdownContentMarked( @@ -3005,6 +3095,14 @@ function parseMarkdownContentMarked(
if (/^https?:\/\/\S+$/i.test(line)) {
const cleaned = cleanUrl(line)
if (cleaned) {
if (isVideo(cleaned) || isAudio(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-line-media-${lineIdx}`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={false} />
</div>
)
}
if (isPseudoNostrHttpsUrl(cleaned)) {
return (
<div key={`${key}-line-http-nostr-${lineIdx}`} className="my-2 not-prose max-w-full">
@ -3072,9 +3170,65 @@ function parseMarkdownContentMarked( @@ -3072,9 +3170,65 @@ function parseMarkdownContentMarked(
}
}
// Inline nostr event IDs can appear as plain text inside a sentence (not link tokens).
// Split paragraph around those IDs so event references render as embedded cards.
const rawInlineNostrMatches = Array.from(rawParagraphText.matchAll(new RegExp(NOSTR_URI_INLINE_REGEX.source, NOSTR_URI_INLINE_REGEX.flags)))
.filter((m) => m.index !== undefined && isNostrEventBech32((m[1] ?? '').toLowerCase()))
if (rawInlineNostrMatches.length > 0) {
const nodes: React.ReactNode[] = []
let cursor = 0
let segmentIdx = 0
for (const match of rawInlineNostrMatches) {
const start = match.index!
const end = start + match[0].length
const bech32Id = String(match[1] ?? '')
const before = rawParagraphText.slice(cursor, start)
if (before.trim().length > 0) {
nodes.push(
<p key={`${key}-nostr-raw-segment-${segmentIdx++}`} className="mb-1 last:mb-0">
{parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)}
</p>
)
}
if (bech32Id.startsWith('naddr') && fullCalendarInvite && bech32Id === fullCalendarInvite.naddr) {
nodes.push(
<div key={`${key}-nostr-raw-calendar-${segmentIdx++}`} className="w-full my-2">
<CalendarEventContent event={fullCalendarInvite.event} className="mt-2" showRsvp />
</div>
)
} else {
nodes.push(
<div key={`${key}-nostr-raw-event-${segmentIdx++}`} className="w-full my-2">
<EmbeddedNote noteId={bech32Id} containingEvent={containingEvent} />
</div>
)
}
cursor = end
}
const after = rawParagraphText.slice(cursor)
if (after.trim().length > 0) {
nodes.push(
<p key={`${key}-nostr-raw-segment-${segmentIdx++}`} className="mb-1 last:mb-0">
{parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)}
</p>
)
}
if (nodes.length > 0) {
return <div key={`${key}-nostr-raw-mix`}>{nodes}</div>
}
}
if (/^https?:\/\/\S+$/i.test(paragraphText)) {
const cleaned = cleanUrl(paragraphText)
if (cleaned) {
if (isVideo(cleaned) || isAudio(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-media-url`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={false} />
</div>
)
}
if (isPseudoNostrHttpsUrl(cleaned)) {
return (
<div key={`${key}-http-nostr`} className="my-2 not-prose max-w-full">
@ -3100,9 +3254,119 @@ function parseMarkdownContentMarked( @@ -3100,9 +3254,119 @@ function parseMarkdownContentMarked(
}
}
const paragraphTokens = token.tokens ?? marked.Lexer.lexInline(token.text ?? '')
const parseNostrHref = (href: string): string | null => {
if (!href.toLowerCase().startsWith('nostr:')) return null
const raw = href.slice(6).trim()
if (!raw) return null
const bech32 = raw.split(/[?#]/)[0]?.replace(/\/+$/, '') || ''
return bech32 || null
}
// Inline nostr event links (e.g. "… nostr:naddr1…") should render embedded cards.
// Split paragraph into inline text segments + block embeds to avoid invalid <p><div/></p> trees.
if (Array.isArray(paragraphTokens) && paragraphTokens.length > 0) {
const hasInlineMediaImageToken = paragraphTokens.some((t) => {
if (t?.type !== 'image') return false
const cleaned = cleanUrl(String(t.href ?? ''))
return !!cleaned && (isVideo(cleaned) || isAudio(cleaned))
})
if (hasInlineMediaImageToken) {
const nodes: React.ReactNode[] = []
let inlineSegment: any[] = []
const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return
nodes.push(
<p key={`${key}-media-inline-segment-${segmentIdx}`} className="mb-1 last:mb-0">
{renderInlineTokens(inlineSegment, `${key}-media-inline-segment-${segmentIdx}`)}
</p>
)
inlineSegment = []
}
let segmentIdx = 0
paragraphTokens.forEach((t: any, idx: number) => {
if (t?.type !== 'image') {
inlineSegment.push(t)
return
}
const src = String(t.href ?? '')
const cleaned = cleanUrl(src)
if (!cleaned || (!isVideo(cleaned) && !isAudio(cleaned))) {
inlineSegment.push(t)
return
}
flushInlineSegment(segmentIdx++)
const poster = videoPosterMap?.get(cleaned)
nodes.push(
<div key={`${key}-inline-media-${idx}`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={false} />
</div>
)
})
flushInlineSegment(segmentIdx++)
if (nodes.length > 0) {
return <div key={`${key}-inline-media-mix`}>{nodes}</div>
}
}
const hasInlineNostrEventLink = paragraphTokens.some((t) => {
if (t?.type !== 'link') return false
const bech32 = parseNostrHref(String(t.href ?? ''))
return !!bech32 && isNostrEventBech32(bech32)
})
if (hasInlineNostrEventLink) {
const nodes: React.ReactNode[] = []
let inlineSegment: any[] = []
const flushInlineSegment = (segmentIdx: number) => {
if (inlineSegment.length === 0) return
nodes.push(
<p key={`${key}-nostr-inline-segment-${segmentIdx}`} className="mb-1 last:mb-0">
{renderInlineTokens(inlineSegment, `${key}-nostr-inline-segment-${segmentIdx}`)}
</p>
)
inlineSegment = []
}
let segmentIdx = 0
paragraphTokens.forEach((t: any, idx: number) => {
if (t?.type !== 'link') {
inlineSegment.push(t)
return
}
const href = String(t.href ?? '')
const bech32 = parseNostrHref(href)
if (!bech32 || !isNostrEventBech32(bech32)) {
inlineSegment.push(t)
return
}
flushInlineSegment(segmentIdx++)
if (bech32.startsWith('naddr') && fullCalendarInvite && bech32 === fullCalendarInvite.naddr) {
nodes.push(
<div key={`${key}-nostr-inline-calendar-${idx}`} className="w-full my-2">
<CalendarEventContent event={fullCalendarInvite.event} className="mt-2" showRsvp />
</div>
)
} else {
nodes.push(
<div key={`${key}-nostr-inline-event-${idx}`} className="w-full my-2">
<EmbeddedNote noteId={bech32} containingEvent={containingEvent} />
</div>
)
}
})
flushInlineSegment(segmentIdx++)
if (nodes.length > 0) {
return <div key={`${key}-nostr-inline-mix`}>{nodes}</div>
}
}
}
// If the paragraph is a single markdown image token, render it as block media/image
// instead of wrapping in <p> (avoids invalid DOM nesting for media players).
const paragraphTokens = token.tokens ?? marked.Lexer.lexInline(token.text ?? '')
if (Array.isArray(paragraphTokens) && paragraphTokens.length === 1 && paragraphTokens[0]?.type === 'image') {
const imageToken = paragraphTokens[0]
const src = String(imageToken.href ?? '')
@ -3116,6 +3380,13 @@ function parseMarkdownContentMarked( @@ -3116,6 +3380,13 @@ function parseMarkdownContentMarked(
</div>
)
}
if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) {
return (
<p key={`${key}-img-inline-fallback`} className="mb-1 last:mb-0">
{renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)}
</p>
)
}
const identifier = getImageIdentifier?.(cleaned)
const thumbnail =
imageThumbnailMap?.get(cleaned) ??
@ -3219,14 +3490,35 @@ function parseMarkdownContentMarked( @@ -3219,14 +3490,35 @@ function parseMarkdownContentMarked(
}
case 'list': {
const ListTag = token.ordered ? 'ol' : 'ul'
const listClass = token.ordered ? 'list-decimal list-outside my-2 ml-6' : 'list-disc list-inside my-2 space-y-1'
const listClass = token.ordered
? 'list-decimal list-outside my-2 ml-6'
: 'list-disc list-outside my-2 ml-6 space-y-1'
const renderListItemContent = (item: any, itemKey: string): React.ReactNode => {
const itemTokens = item.tokens ?? [{ type: 'text', text: item.text ?? '' }]
if (itemTokens.length === 1) {
const single = itemTokens[0]
if (single.type === 'text') {
return renderInlineTokens(
single.tokens ?? marked.Lexer.lexInline(single.text ?? ''),
`${itemKey}-inline`
)
}
if (single.type === 'paragraph') {
return renderInlineTokens(
single.tokens ?? marked.Lexer.lexInline(single.text ?? ''),
`${itemKey}-inline`
)
}
}
return renderBlockTokens(itemTokens, itemKey)
}
nodes.push(
React.createElement(
ListTag,
{ key: `${key}-list`, className: listClass },
(token.items ?? []).map((item: any, itemIdx: number) => (
<li key={`${key}-li-${itemIdx}`}>
{renderBlockTokens(item.tokens ?? [{ type: 'text', text: item.text ?? '' }], `${key}-li-${itemIdx}`)}
{renderListItemContent(item, `${key}-li-${itemIdx}`)}
</li>
))
)
@ -3285,6 +3577,34 @@ function parseMarkdownContentMarked( @@ -3285,6 +3577,34 @@ function parseMarkdownContentMarked(
}
const nodes = renderBlockTokens(blockTokens, 'marked-root')
if (footnotes.size > 0) {
nodes.push(
<div key="footnotes-section" className="mt-8 pt-4 border-t border-gray-300 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4">Footnotes</h3>
<ol className="list-decimal list-inside space-y-2">
{Array.from(footnotes.entries()).map(([id, text]) => (
<li key={`footnote-${id}`} id={`footnote-${id}`} className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-semibold">[{id}]:</span>{' '}
<span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, navigateToHashtag)}</span>{' '}
<a
href={`#footnote-ref-${id}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs"
onClick={(e) => {
e.preventDefault()
const refElement = document.getElementById(`footnote-ref-${id}`)
if (refElement) {
refElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
>
</a>
</li>
))}
</ol>
</div>
)
}
return { nodes, hashtagsInContent, footnotes, citations }
}
@ -3374,9 +3694,9 @@ function parseInlineMarkdown( @@ -3374,9 +3694,9 @@ function parseInlineMarkdown(
if (token.type === 'link') {
const href = String(token.href ?? '')
const children = renderTokens(
token.tokens ?? [{ type: 'text', text: token.text ?? href }],
`${tokenKey}-link`
const children = stripNestedAnchorsFromNodes(
renderTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${tokenKey}-link`),
`${tokenKey}-link-sanitized`
)
if (href.startsWith('payto://')) {
out.push(
@ -3460,165 +3780,16 @@ function parseInlineMarkdownLegacy( @@ -3460,165 +3780,16 @@ function parseInlineMarkdownLegacy(
let lastIndex = 0
const inlinePatterns: Array<{ index: number; end: number; type: string; data: any }> = []
// Inline code: ``code`` (double backtick) or `code` (single backtick) - process first to avoid conflicts
// Double backticks first
const doubleCodeRegex = /``([^`\n]+?)``/g
const doubleCodeMatches = Array.from(text.matchAll(doubleCodeRegex))
doubleCodeMatches.forEach(match => {
if (match.index !== undefined) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'code',
data: match[1]
})
}
})
// Single backtick (but not if already in double backtick)
const singleCodeRegex = /`([^`\n]+?)`/g
const singleCodeMatches = Array.from(text.matchAll(singleCodeRegex))
singleCodeMatches.forEach(match => {
if (match.index !== undefined) {
const isInDoubleCode = inlinePatterns.some(p =>
p.type === 'code' &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInDoubleCode) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'code',
data: match[1]
})
}
}
})
// Bold: **text** (double asterisk) or __text__ (double underscore) - process first
// Also handle *text* (single asterisk) as bold
// Allow single newlines within bold spans (but not double newlines which indicate paragraph breaks)
const doubleBoldAsteriskRegex = /\*\*((?:[^\n]|\n(?!\n))+\n?)\*\*/g
const doubleBoldAsteriskMatches = Array.from(text.matchAll(doubleBoldAsteriskRegex))
doubleBoldAsteriskMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code
const isInCode = inlinePatterns.some(p =>
p.type === 'code' &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInCode) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'bold',
data: match[1]
})
}
}
})
// Double underscore bold (but check if it's already italic)
// Allow single newlines within bold spans (but not double newlines which indicate paragraph breaks)
const doubleBoldUnderscoreRegex = /__((?:[^\n]|\n(?!\n))+\n?)__/g
const doubleBoldUnderscoreMatches = Array.from(text.matchAll(doubleBoldUnderscoreRegex))
doubleBoldUnderscoreMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code or bold
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'bold',
data: match[1]
})
}
}
})
// Single asterisk bold: *text* (not part of **bold**)
const singleBoldAsteriskRegex = /(?<!\*)\*([^*\n]+?)\*(?!\*)/g
const singleBoldAsteriskMatches = Array.from(text.matchAll(singleBoldAsteriskRegex))
singleBoldAsteriskMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code, double bold, or strikethrough
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'strikethrough') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'bold',
data: match[1]
})
}
}
})
// Strikethrough: ~~text~~ (double tilde) or ~text~ (single tilde)
// Double tildes first
const doubleStrikethroughRegex = /~~(.+?)~~/g
const doubleStrikethroughMatches = Array.from(text.matchAll(doubleStrikethroughRegex))
doubleStrikethroughMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code or bold
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'strikethrough',
data: match[1]
})
}
}
})
// Single tilde strikethrough
const singleStrikethroughRegex = /(?<!~)~([^~\n]+?)~(?!~)/g
const singleStrikethroughMatches = Array.from(text.matchAll(singleStrikethroughRegex))
singleStrikethroughMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code, bold, or double strikethrough
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'strikethrough') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'strikethrough',
data: match[1]
})
}
}
})
// Italic: _text_ (single underscore) or __text__ (double underscore, but bold takes priority)
// Single underscore italic (not part of __bold__)
const singleItalicUnderscoreRegex = /(?<!_)_([^_\n]+?)_(?!_)/g
const singleItalicUnderscoreMatches = Array.from(text.matchAll(singleItalicUnderscoreRegex))
singleItalicUnderscoreMatches.forEach(match => {
// Legacy helper is intentionally narrowed to non-standard enrichments.
// Standard markdown emphasis/code is handled by marked in parseInlineMarkdown().
// Markdown links are still recognized here for plain-text/fallback inline fragments.
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
const markdownLinkMatches = Array.from(text.matchAll(markdownLinkRegex))
markdownLinkMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code, bold, or strikethrough
// Skip if already in code, bold, italic, or strikethrough
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'strikethrough') &&
(p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough') &&
match.index! >= p.index &&
match.index! < p.end
)
@ -3626,25 +3797,23 @@ function parseInlineMarkdownLegacy( @@ -3626,25 +3797,23 @@ function parseInlineMarkdownLegacy(
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'italic',
data: match[1]
type: 'link',
data: { text: match[1], url: match[2] }
})
}
}
})
// Double underscore italic (only if not already bold)
// Note: __text__ is bold by default, but if user wants it italic, we can add it
// For now, we'll keep __text__ as bold only, and _text_ as italic
// Markdown links: [text](url) - but not images (process after code/bold/italic to avoid conflicts)
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
const markdownLinkMatches = Array.from(text.matchAll(markdownLinkRegex))
markdownLinkMatches.forEach(match => {
// Footnote references: [^id]
// Only render as clickable refs when the referenced definition exists.
const footnoteRefRegex = /\[\^([^\]]+)\]/g
const footnoteRefMatches = Array.from(text.matchAll(footnoteRefRegex))
footnoteRefMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code, bold, italic, or strikethrough
const footnoteId = match[1]
if (!_footnotes.has(footnoteId)) return
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough') &&
(p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@ -3652,8 +3821,8 @@ function parseInlineMarkdownLegacy( @@ -3652,8 +3821,8 @@ function parseInlineMarkdownLegacy(
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'link',
data: { text: match[1], url: match[2] }
type: 'footnote-ref',
data: footnoteId
})
}
}
@ -3664,9 +3833,9 @@ function parseInlineMarkdownLegacy( @@ -3664,9 +3833,9 @@ function parseInlineMarkdownLegacy(
const hashtagMatches = Array.from(text.matchAll(hashtagRegex))
hashtagMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code, bold, italic, strikethrough, link, relay-url, nostr, or payto
// Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
(p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@ -3688,9 +3857,9 @@ function parseInlineMarkdownLegacy( @@ -3688,9 +3857,9 @@ function parseInlineMarkdownLegacy(
const url = match[0]
// Only process if it's actually a websocket URL
if (isWebsocketUrl(url)) {
// Skip if already in code, bold, italic, strikethrough, link, hashtag, or nostr
// Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr') &&
(p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@ -3717,9 +3886,9 @@ function parseInlineMarkdownLegacy( @@ -3717,9 +3886,9 @@ function parseInlineMarkdownLegacy(
const isProfileType = bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')
if (isProfileType) {
// Skip if already in code, bold, italic, strikethrough, link, hashtag, or relay-url
// Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
(p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@ -3743,7 +3912,7 @@ function parseInlineMarkdownLegacy( @@ -3743,7 +3912,7 @@ function parseInlineMarkdownLegacy(
const parsed = parsePaytoUri(fullMatch)
if (!parsed) return
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
(p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@ -3763,7 +3932,7 @@ function parseInlineMarkdownLegacy( @@ -3763,7 +3932,7 @@ function parseInlineMarkdownLegacy(
emojiMatches.forEach(match => {
if (match.index !== undefined) {
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto' || p.type === 'emoji') &&
(p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto' || p.type === 'emoji') &&
match.index! >= p.index &&
match.index! < p.end
)
@ -3809,22 +3978,8 @@ function parseInlineMarkdownLegacy( @@ -3809,22 +3978,8 @@ function parseInlineMarkdownLegacy(
}
}
// Render pattern
if (pattern.type === 'bold') {
parts.push(<strong key={`${keyPrefix}-bold-${i}`}>{pattern.data}</strong>)
} else if (pattern.type === 'italic') {
parts.push(<em key={`${keyPrefix}-italic-${i}`}>{pattern.data}</em>)
} else if (pattern.type === 'strikethrough') {
parts.push(<del key={`${keyPrefix}-strikethrough-${i}`} className="line-through">{pattern.data}</del>)
} else if (pattern.type === 'code') {
parts.push(
<InlineCode
key={`${keyPrefix}-code-${i}`}
keyPrefix={`${keyPrefix}-code-${i}`}
code={pattern.data}
/>
)
} else if (pattern.type === 'link') {
// Render custom inline pattern
if (pattern.type === 'link') {
const { text, url } = pattern.data
if (url.startsWith('payto://')) {
parts.push(
@ -3870,6 +4025,26 @@ function parseInlineMarkdownLegacy( @@ -3870,6 +4025,26 @@ function parseInlineMarkdownLegacy(
#{tag}
</a>
)
} else if (pattern.type === 'footnote-ref') {
const footnoteId = pattern.data
parts.push(
<sup key={`${keyPrefix}-footnote-${i}`} className="footnote-ref">
<a
href={`#footnote-${footnoteId}`}
id={`footnote-ref-${footnoteId}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs"
onClick={(e) => {
e.preventDefault()
const footnoteElement = document.getElementById(`footnote-${footnoteId}`)
if (footnoteElement) {
footnoteElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
>
[{footnoteId}]
</a>
</sup>
)
} else if (pattern.type === 'relay-url') {
// Render relay URLs as inline links (green to match theme)
const url = pattern.data
@ -4363,7 +4538,13 @@ export default function MarkdownArticle({ @@ -4363,7 +4538,13 @@ export default function MarkdownArticle({
suppressStandaloneWebPreviewCleanedUrls:
webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined
}
const result = parseMarkdownContentMarked(preprocessedContent, parseOptions)
let result
try {
result = parseMarkdownContentMarked(preprocessedContent, parseOptions)
} catch (error) {
logger.error('Marked parser failed, falling back to legacy parser:', error)
result = parseMarkdownContentLegacy(preprocessedContent, parseOptions)
}
// Return nodes and hashtags (footnotes are already included in nodes)
return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent }
}, [

180
src/components/ProfileAbout/index.tsx

@ -8,6 +8,7 @@ import { @@ -8,6 +8,7 @@ import {
} from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import PaytoLink from '@/components/PaytoLink'
import { marked } from 'marked'
import {
EmbeddedHashtag,
EmbeddedMention,
@ -17,7 +18,27 @@ import { @@ -17,7 +18,27 @@ import {
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
const normalized = replaceStandardEmojiShortcodesInContent(about ?? '', [])
const aboutNodes = parseContent(normalized, [
if (!normalized.trim()) return null
const renderEnrichedText = (text: string, keyPrefix: string): React.ReactNode[] => {
if (text.length === 0) return []
const leadingWs = text.match(/^\s+/)?.[0] ?? ''
const trailingWs = text.match(/\s+$/)?.[0] ?? ''
const coreStart = leadingWs.length
const coreEnd = text.length - trailingWs.length
const core = text.slice(coreStart, coreEnd)
const out: React.ReactNode[] = []
if (leadingWs) {
out.push(
<span key={`${keyPrefix}-leading-ws`} className="whitespace-pre-wrap">
{leadingWs}
</span>
)
}
if (core) {
const coreNodes = parseContent(core, [
EmbeddedWebsocketUrlParser,
EmbeddedUrlParser,
EmbeddedPaytoParser,
@ -25,28 +46,171 @@ export default function ProfileAbout({ about, className }: { about?: string; cla @@ -25,28 +46,171 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
EmbeddedMentionParser
]).map((node, index) => {
if (node.type === 'url') {
return <EmbeddedNormalUrl key={index} url={node.data} />
return <EmbeddedNormalUrl key={`${keyPrefix}-url-${index}`} url={node.data} />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl key={index} url={node.data} />
return <EmbeddedWebsocketUrl key={`${keyPrefix}-ws-${index}`} url={node.data} />
}
if (node.type === 'payto') {
return (
<PaytoLink
key={index}
key={`${keyPrefix}-payto-${index}`}
paytoUri={node.data}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
/>
)
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag key={index} hashtag={node.data} />
return <EmbeddedHashtag key={`${keyPrefix}-hashtag-${index}`} hashtag={node.data} />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
return <EmbeddedMention key={`${keyPrefix}-mention-${index}`} userId={node.data.split(':')[1]} />
}
return node.data
return <span key={`${keyPrefix}-text-${index}`}>{node.data}</span>
})
out.push(...coreNodes)
}
if (trailingWs) {
out.push(
<span key={`${keyPrefix}-trailing-ws`} className="whitespace-pre-wrap">
{trailingWs}
</span>
)
}
return out
}
const renderInlineTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => {
const out: React.ReactNode[] = []
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const key = `${keyPrefix}-${i}`
if (token.type === 'text' || token.type === 'escape') {
out.push(...renderEnrichedText(String(token.text ?? token.raw ?? ''), `${key}-txt`))
} else if (token.type === 'strong') {
out.push(
<strong key={`${key}-strong`}>
{renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-strong`)}
</strong>
)
} else if (token.type === 'em') {
out.push(
<em key={`${key}-em`}>
{renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-em`)}
</em>
)
} else if (token.type === 'del') {
out.push(
<del key={`${key}-del`} className="line-through">
{renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-del`)}
</del>
)
} else if (token.type === 'codespan') {
out.push(
<code key={`${key}-code`} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.9em]">
{String(token.text ?? '')}
</code>
)
} else if (token.type === 'br') {
out.push(<br key={`${key}-br`} />)
} else if (token.type === 'link') {
const href = String(token.href ?? '')
const label = String(token.text ?? href)
if (href.startsWith('payto://')) {
out.push(
<PaytoLink
key={`${key}-payto-link`}
paytoUri={href}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
>
{label}
</PaytoLink>
)
} else {
out.push(
<a
key={`${key}-link`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
>
{label}
</a>
)
}
} else {
out.push(...renderEnrichedText(String(token.raw ?? token.text ?? ''), `${key}-fallback`))
}
}
return out
}
const renderBlocks = (content: string): React.ReactNode[] => {
const blocks = marked.lexer(content, { gfm: true, breaks: true }) as any[]
const nodes: React.ReactNode[] = []
for (let i = 0; i < blocks.length; i++) {
const token = blocks[i]
const key = `about-block-${i}`
if (token.type === 'space') continue
if (token.type === 'paragraph') {
nodes.push(
<p key={`${key}-p`} className="mb-1 last:mb-0">
{renderInlineTokens(token.tokens ?? marked.Lexer.lexInline(token.text ?? ''), `${key}-inline`)}
</p>
)
continue
}
if (token.type === 'list') {
const ListTag = token.ordered ? 'ol' : 'ul'
const listClass = token.ordered ? 'list-decimal list-outside ml-5 my-1' : 'list-disc list-outside ml-5 my-1'
nodes.push(
<ListTag key={`${key}-list`} className={listClass}>
{(token.items ?? []).map((item: any, idx: number) => (
<li key={`${key}-li-${idx}`}>
{renderInlineTokens(item.tokens ?? marked.Lexer.lexInline(item.text ?? ''), `${key}-li-${idx}`)}
</li>
))}
</ListTag>
)
continue
}
if (token.type === 'heading') {
const level = Math.min(Math.max(Number(token.depth || 1), 1), 6)
const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements
nodes.push(
<HeadingTag key={`${key}-h`} className="mt-2 mb-1 font-semibold break-words">
{renderInlineTokens(token.tokens ?? marked.Lexer.lexInline(token.text ?? ''), `${key}-heading-inline`)}
</HeadingTag>
)
continue
}
if (token.type === 'blockquote') {
nodes.push(
<blockquote key={`${key}-bq`} className="my-2 border-l-2 border-border pl-3 text-muted-foreground">
{renderBlocks(String(token.text ?? token.raw ?? ''))}
</blockquote>
)
continue
}
if (token.type === 'code') {
nodes.push(
<pre key={`${key}-code`} className="my-2 overflow-x-auto rounded bg-muted p-2 text-xs">
<code>{String(token.text ?? '')}</code>
</pre>
)
continue
}
nodes.push(
<p key={`${key}-fallback`} className="mb-1 last:mb-0">
{renderInlineTokens(marked.Lexer.lexInline(String(token.text ?? token.raw ?? '')), `${key}-fallback-inline`)}
</p>
)
}
return nodes
}
return <div className={className}>{aboutNodes}</div>
return <div className={className}>{renderBlocks(normalized)}</div>
}

8
src/components/WebPreview/index.tsx

@ -21,6 +21,7 @@ import { FAST_READ_RELAY_URLS } from '@/constants' @@ -21,6 +21,7 @@ import { FAST_READ_RELAY_URLS } from '@/constants'
import { getImetaInfosFromEvent } from '@/lib/event'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import AsciidocArticle from '../Note/AsciidocArticle/AsciidocArticle'
import ProfileAbout from '@/components/ProfileAbout'
// Helper function to get event type name
function getEventTypeName(kind: number): string {
@ -666,9 +667,10 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -666,9 +667,10 @@ export default function WebPreview({ url, className }: { url: string; className?
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400" />
</a>
</div>
{fetchedProfile?.about && (
<div className="text-base text-muted-foreground line-clamp-2 mb-1 mt-1 break-words">{fetchedProfile.about}</div>
)}
<ProfileAbout
about={fetchedProfile?.about}
className="text-base text-muted-foreground line-clamp-2 mb-1 mt-1 break-words"
/>
<hr className="mt-4 mb-2 border-t border-border" />
<a
href={cleanedUrl}

41
src/pages/primary/NoteListPage/FollowingFeed.tsx

@ -1,7 +1,9 @@ @@ -1,7 +1,9 @@
import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import { augmentSubRequestsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
import { getPubkeysFromPTags } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import logger from '@/lib/logger'
import { useFeed } from '@/providers/FeedProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
@ -17,7 +19,7 @@ const FollowingFeed = forwardRef< @@ -17,7 +19,7 @@ const FollowingFeed = forwardRef<
onSubHeaderRefresh?: () => void
}
>(function FollowingFeed({ setSubHeader, onSubHeaderRefresh }, ref) {
const { pubkey, relayList } = useNostr()
const { pubkey, relayList, followListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { feedInfo } = useFeed()
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
@ -60,27 +62,54 @@ const FollowingFeed = forwardRef< @@ -60,27 +62,54 @@ const FollowingFeed = forwardRef<
)
useEffect(() => {
let cancelled = false
async function init() {
if (feedInfo.feedType !== 'following' || !pubkey) {
setSubRequests([])
return
}
const followings = await client.fetchFollowings(pubkey)
let followings: string[] = []
try {
followings = await client.fetchFollowings(pubkey)
} catch (error) {
// Failsafe: keep follows feed usable when contacts fetch relay calls fail transiently.
followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
logger.warn('[FollowingFeed] fetchFollowings failed; using cached follow list fallback', {
error,
fallbackCount: followings.length
})
}
try {
const raw = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey)
setSubRequests(
augmentSubRequestsWithFavoritesFastReadAndInbox(
const augmented = augmentSubRequestsWithFavoritesFastReadAndInbox(
raw,
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] }
)
)
if (!cancelled) setSubRequests(augmented)
} catch (error) {
logger.error('[FollowingFeed] generateSubRequestsForPubkeys failed', error)
if (!cancelled) setSubRequests([])
}
}
void init()
}, [feedInfo.feedType, pubkey, favoriteRelaysKey, blockedRelaysKey, relayReadKey, relayWriteKey])
return () => {
cancelled = true
}
}, [
feedInfo.feedType,
pubkey,
followListEvent?.id,
favoriteRelaysKey,
blockedRelaysKey,
relayReadKey,
relayWriteKey
])
return (
<NormalFeed

3
src/pages/secondary/MuteListPage/index.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import JsonViewDialog from '@/components/JsonViewDialog'
import MuteButton from '@/components/MuteButton'
import Nip05 from '@/components/Nip05'
import ProfileAbout from '@/components/ProfileAbout'
import { RefreshButton } from '@/components/RefreshButton'
import {
AlertDialog,
@ -234,7 +235,7 @@ function UserItem({ pubkey }: { pubkey: string }) { @@ -234,7 +235,7 @@ function UserItem({ pubkey }: { pubkey: string }) {
skeletonClassName="h-4"
/>
<Nip05 pubkey={pubkey} />
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
<ProfileAbout about={profile?.about} className="line-clamp-2 text-muted-foreground text-sm break-words" />
</div>
<div className="flex gap-2 items-center">
{switching ? (

20
src/providers/FavoriteRelaysActivityProvider.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import logger from '@/lib/logger'
import { ExtendedKind } from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import {
readRelayPulseActiveNpubsCache,
@ -23,8 +24,21 @@ const ACTIVE_WINDOW_SEC = 3600 @@ -23,8 +24,21 @@ const ACTIVE_WINDOW_SEC = 3600
const FETCH_RETRY_DELAY_MS = 2500
/** Wall-clock cadence while the tab is visible */
const POLL_INTERVAL_MS = 60 * 60 * 1000
/** Enough events to surface many distinct authors without overloading relays */
const REQ_LIMIT = 400
/** Event cap for relay pulse query. This is event-count (not author-count): keep high enough for >120 active npubs. */
const REQ_LIMIT = 500
/** Keep relay pulse focused on note-like activity to avoid expensive all-kind signature verification bursts. */
const ACTIVE_PULSE_KINDS = [
kinds.ShortTextNote,
kinds.Repost,
kinds.LongFormArticle,
kinds.Highlights,
ExtendedKind.DISCUSSION,
ExtendedKind.PICTURE,
ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO,
ExtendedKind.COMMENT,
ExtendedKind.GENERIC_REPOST
] as number[]
function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] {
const lastByPk = new Map<string, number>()
@ -120,7 +134,7 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R @@ -120,7 +134,7 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
try {
const events = await queryService.fetchEvents(
urls,
{ since, limit: REQ_LIMIT },
{ since, limit: REQ_LIMIT, kinds: [...ACTIVE_PULSE_KINDS] },
{
firstRelayResultGraceMs: false,
eoseTimeout: 1800,

Loading…
Cancel
Save