Browse Source

bug-fixes

imwald
Silberengel 2 months ago
parent
commit
e2cf9c4166
  1. 1520
      package-lock.json
  2. 5
      package.json
  3. 9
      src/components/Content/index.tsx
  4. 9
      src/components/ContentPreview/Content.tsx
  5. 153
      src/components/NormalFeed/index.tsx
  6. 106
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  7. 50
      src/components/NotificationList/index.tsx
  8. 3
      src/constants.ts
  9. 16
      src/lib/nostr-parser.tsx
  10. 14
      src/pages/primary/DiscussionsPage/ThreadCard.tsx
  11. 2
      src/pages/primary/DiscussionsPage/index.tsx
  12. 4
      src/pages/primary/MePage/index.tsx
  13. 9
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  14. 19
      src/pages/primary/NoteListPage/index.tsx
  15. 46
      src/pages/primary/NotificationListPage/index.tsx
  16. 4
      src/pages/primary/ProfilePage/index.tsx
  17. 4
      src/pages/primary/RelayPage/index.tsx
  18. 2
      src/pages/primary/SearchPage/index.tsx

1520
package-lock.json generated

File diff suppressed because it is too large Load Diff

5
package.json

@ -62,7 +62,6 @@ @@ -62,7 +62,6 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dataloader": "^2.2.3",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0",
@ -84,11 +83,7 @@ @@ -84,11 +83,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.2.0",
"react-katex": "^3.0.1",
"react-simple-pull-to-refresh": "^1.3.3",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",

9
src/components/Content/index.tsx

@ -10,6 +10,7 @@ import { @@ -10,6 +10,7 @@ import {
parseContent
} from '@/lib/content-parser'
import logger from '@/lib/logger'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
@ -455,10 +456,12 @@ export default function Content({ @@ -455,10 +456,12 @@ export default function Content({
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const shortcode = node.data.slice(1, -1).trim()
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
if (emoji) return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) return <Emoji classNames={{ img: 'mb-1' }} emoji={native.emoji} key={index} />
return <span key={index}>{node.data}</span>
}
if (node.type === 'youtube') {
return (

9
src/components/ContentPreview/Content.tsx

@ -5,6 +5,7 @@ import { @@ -5,6 +5,7 @@ import {
EmbeddedUrlParser,
parseContent
} from '@/lib/content-parser'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { cn } from '@/lib/utils'
import { TEmoji } from '@/types'
import { useMemo } from 'react'
@ -47,10 +48,12 @@ export default function Content({ @@ -47,10 +48,12 @@ export default function Content({
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const shortcode = node.data.slice(1, -1).trim()
const emoji = emojiInfos?.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} classNames={{ img: 'size-4' }} />
if (emoji) return <Emoji key={index} emoji={emoji} classNames={{ img: 'size-4' }} />
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) return <Emoji key={index} emoji={native.emoji} classNames={{ img: 'size-4' }} />
return node.data
}
return node.data
})}

153
src/components/NormalFeed/index.tsx

@ -5,7 +5,7 @@ import { useKindFilter } from '@/providers/KindFilterProvider' @@ -5,7 +5,7 @@ import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types'
import { forwardRef, useMemo, useRef, useState, useEffect } from 'react'
import { forwardRef, useLayoutEffect, useMemo, useRef, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton'
@ -21,11 +21,14 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -21,11 +21,14 @@ const NormalFeed = forwardRef<TNoteListRef, {
areAlgoRelays?: boolean
isMainFeed?: boolean
showRelayCloseReason?: boolean
/** When set (e.g. on Home), tabs are rendered in layout subHeader instead of in-feed; avoids overlap */
setSubHeader?: (node: React.ReactNode) => void
}>(function NormalFeed({
subRequests,
areAlgoRelays = false,
isMainFeed = false,
showRelayCloseReason = false
showRelayCloseReason = false,
setSubHeader
}, ref) {
logger.debug('NormalFeed component rendering with:', { subRequests, areAlgoRelays, isMainFeed })
const { t } = useTranslation()
@ -186,81 +189,83 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -186,81 +189,83 @@ const NormalFeed = forwardRef<TNoteListRef, {
// Determine current tab value
const currentTabValue = activeTab
return (
<>
<Tabs
value={currentTabValue}
tabs={tabs}
onTabChange={(tab) => {
handleListModeChange(tab)
}}
options={
<>
{activeTab === 'rss' && showRssFeed && (
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => {
window.dispatchEvent(new CustomEvent('toggleRssFilters'))
}}
title={t('Toggle filters')}
>
<Search className="h-4 w-4" />
</Button>
)}
<RefreshButton onClick={() => {
if (activeTab === 'rss') {
// Refresh RSS feeds
// Get feed URLs from event or use default
let feedUrls: string[] = []
if (pubkey && rssFeedListEvent) {
// User has an event - use only feeds from that event (even if empty)
try {
const urls = rssFeedListEvent.tags
.filter(tag => tag[0] === 'u' && tag[1])
.map(tag => tag[1] as string)
.filter((url): url is string => {
if (typeof url !== 'string') return false
const trimmed = url.trim()
return trimmed.length > 0
})
feedUrls = urls // Use even if empty (respect user's choice)
} catch (e) {
// On parse error, treat as empty event
feedUrls = []
}
} else {
// No event exists - use default feeds for demo
feedUrls = DEFAULT_RSS_FEEDS
const tabsElement = (
<Tabs
value={currentTabValue}
tabs={tabs}
onTabChange={(tab) => {
handleListModeChange(tab)
}}
options={
<>
{activeTab === 'rss' && showRssFeed && (
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => {
window.dispatchEvent(new CustomEvent('toggleRssFilters'))
}}
title={t('Toggle filters')}
>
<Search className="h-4 w-4" />
</Button>
)}
<RefreshButton onClick={() => {
if (activeTab === 'rss') {
let feedUrls: string[] = []
if (pubkey && rssFeedListEvent) {
try {
const urls = rssFeedListEvent.tags
.filter(tag => tag[0] === 'u' && tag[1])
.map(tag => tag[1] as string)
.filter((url): url is string => {
if (typeof url !== 'string') return false
const trimmed = url.trim()
return trimmed.length > 0
})
feedUrls = urls
} catch (e) {
feedUrls = []
}
// Trigger background refresh and UI update
logger.info('[NormalFeed] Manual refresh: triggering RSS background refresh', { feedCount: feedUrls.length })
// Start background refresh (don't wait for it)
rssFeedService.backgroundRefreshFeeds(feedUrls).catch(err => {
logger.error('[NormalFeed] Manual refresh: background refresh failed', { error: err })
})
// Immediately trigger UI update (will show cached items, then update when background refresh completes)
if (pubkey) {
window.dispatchEvent(new CustomEvent('rssFeedListUpdated', {
detail: { pubkey, feedUrls, eventId: 'manual-refresh' }
}))
}
// Also force re-render by updating key
setRssRefreshKey(prev => prev + 1)
} else {
// Refresh Notes/Replies
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh()
}
feedUrls = DEFAULT_RSS_FEEDS
}
}} />
{activeTab !== 'rss' && (
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
)}
</>
}
/>
logger.info('[NormalFeed] Manual refresh: triggering RSS background refresh', { feedCount: feedUrls.length })
rssFeedService.backgroundRefreshFeeds(feedUrls).catch(err => {
logger.error('[NormalFeed] Manual refresh: background refresh failed', { error: err })
})
if (pubkey) {
window.dispatchEvent(new CustomEvent('rssFeedListUpdated', {
detail: { pubkey, feedUrls, eventId: 'manual-refresh' }
}))
}
setRssRefreshKey(prev => prev + 1)
} else {
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh()
}
}
}} />
{activeTab !== 'rss' && (
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
)}
</>
}
/>
)
// When used on Home, render tabs in layout subHeader so they don't overlap content
useLayoutEffect(() => {
if (!isMainFeed || !setSubHeader) return
setSubHeader(tabsElement)
return () => setSubHeader(null)
}, [isMainFeed, setSubHeader, currentTabValue, activeTab, showRssFeed, temporaryShowKinds])
const renderTabsInFeed = !(isMainFeed && setSubHeader)
return (
<>
{renderTabsInFeed && tabsElement}
<div className="pt-2 min-w-0">
{activeTab === 'rss' ? (
<RssFeedList key={rssRefreshKey} />

106
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -11,7 +11,11 @@ import { useMediaExtraction } from '@/hooks' @@ -11,7 +11,11 @@ import { useMediaExtraction } from '@/hooks'
import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
import { ExtendedKind, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import Emoji from '@/components/Emoji'
import { ExtendedKind, EMOJI_SHORT_CODE_REGEX, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji } from '@/types'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox'
@ -417,9 +421,10 @@ function parseMarkdownContent( @@ -417,9 +421,10 @@ function parseMarkdownContent(
videoPosterMap?: Map<string, string>
imageThumbnailMap?: Map<string, string>
getImageIdentifier?: (url: string) => string | null
emojiInfos?: TEmoji[]
}
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string>; citations: Array<{ id: string; type: string; citationId: string }> } {
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap, imageThumbnailMap, getImageIdentifier } = options
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap, imageThumbnailMap, getImageIdentifier, emojiInfos = [] } = options
const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>()
@ -1442,17 +1447,22 @@ function parseMarkdownContent( @@ -1442,17 +1447,22 @@ function parseMarkdownContent(
// Also update lastIndex immediately to prevent processing of patterns in this range
lastIndex = textEndIndex
} else if (hasTextOnSameLine || hasTextBefore || hasTextAfterOnSameLine) {
// Hashtag is part of text - merge just this hashtag and text after it (avoids hard break after #hashtag at start of line)
// Hashtag is part of text - merge this hashtag and all following hashtags/text on same line (avoids hard break between #hashtag #other)
const patternMarkdown = content.substring(pattern.index, pattern.end)
const textAfterPattern = content.substring(pattern.end, lineEndIndex)
text = text + patternMarkdown + textAfterPattern
textEndIndex = lineEndIndex === content.length ? content.length : lineEndIndex + 1
const tag = pattern.data
const tagLower = tag.toLowerCase()
hashtagsInContent.add(tagLower)
// Mark as merged BEFORE processing text to ensure it's skipped
mergedPatterns.add(patternIdx)
// Mark every hashtag in this merged range so we don't render them as separate blocks
const mergeStartIndex = pattern.index
const mergeEndIndex = lineEndIndex
filteredPatterns.forEach((p, idx) => {
if (p.type === 'hashtag' && p.index >= mergeStartIndex && p.index < mergeEndIndex) {
const tag = p.data
hashtagsInContent.add(tag.toLowerCase())
mergedPatterns.add(idx)
}
})
}
} else if ((pattern.type === 'markdown-link' || pattern.type === 'relay-url') && (hasTextOnSameLine || hasTextBefore)) {
// Get the original pattern syntax from the content
@ -1523,7 +1533,7 @@ function parseMarkdownContent( @@ -1523,7 +1533,7 @@ function parseMarkdownContent(
normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ')
normalizedText = normalizedText.trim()
if (normalizedText) {
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes)
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes, emojiInfos)
parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`} className="mb-1 last:mb-0">
{textContent}
@ -1586,7 +1596,7 @@ function parseMarkdownContent( @@ -1586,7 +1596,7 @@ function parseMarkdownContent(
normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ')
normalizedText = normalizedText.trim()
if (normalizedText) {
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes)
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes, emojiInfos)
parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}-final`} className="mb-1 last:mb-0">
{textContent}
@ -1606,7 +1616,7 @@ function parseMarkdownContent( @@ -1606,7 +1616,7 @@ function parseMarkdownContent(
normalizedPara = normalizedPara.trim()
if (normalizedPara) {
// Process paragraph for inline formatting (which will handle markdown links)
const paraContent = parseInlineMarkdown(normalizedPara, `text-${patternIdx}-para-${paraIdx}`, footnotes)
const paraContent = parseInlineMarkdown(normalizedPara, `text-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos)
// Wrap in paragraph tag (no whitespace-pre-wrap, let normal text wrapping handle it)
parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}`} className="mb-1 last:mb-0">
@ -1813,7 +1823,7 @@ function parseMarkdownContent( @@ -1813,7 +1823,7 @@ function parseMarkdownContent(
} 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)
const linkContent = parseInlineMarkdown(text, `link-${patternIdx}`, footnotes, emojiInfos)
// 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)) {
@ -1882,7 +1892,7 @@ function parseMarkdownContent( @@ -1882,7 +1892,7 @@ function parseMarkdownContent(
} else if (pattern.type === 'header') {
const { level, text } = pattern.data
// Parse the header text for inline formatting (but not nested headers)
const headerContent = parseInlineMarkdown(text, `header-${patternIdx}`, footnotes)
const headerContent = parseInlineMarkdown(text, `header-${patternIdx}`, footnotes, emojiInfos)
const HeaderTag = `h${Math.min(level, 6)}` as keyof JSX.IntrinsicElements
parts.push(
<HeaderTag
@ -1905,7 +1915,7 @@ function parseMarkdownContent( @@ -1905,7 +1915,7 @@ function parseMarkdownContent(
)
} else if (pattern.type === 'bullet-list-item') {
const { text } = pattern.data
const listContent = parseInlineMarkdown(text, `bullet-${patternIdx}`, footnotes)
const listContent = parseInlineMarkdown(text, `bullet-${patternIdx}`, footnotes, emojiInfos)
parts.push(
<li key={`bullet-${patternIdx}`} className="list-disc list-inside my-1">
{listContent}
@ -1913,7 +1923,7 @@ function parseMarkdownContent( @@ -1913,7 +1923,7 @@ function parseMarkdownContent(
)
} else if (pattern.type === 'numbered-list-item') {
const { text, number } = pattern.data
const listContent = parseInlineMarkdown(text, `numbered-${patternIdx}`, footnotes)
const listContent = parseInlineMarkdown(text, `numbered-${patternIdx}`, footnotes, emojiInfos)
const itemNumber = number ? parseInt(number, 10) : undefined
parts.push(
<li key={`numbered-${patternIdx}`} className="leading-tight" value={itemNumber}>
@ -1935,7 +1945,7 @@ function parseMarkdownContent( @@ -1935,7 +1945,7 @@ function parseMarkdownContent(
key={`th-${patternIdx}-${cellIdx}`}
className="border border-gray-300 dark:border-gray-700 px-4 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-left"
>
{parseInlineMarkdown(cell, `table-header-${patternIdx}-${cellIdx}`, footnotes)}
{parseInlineMarkdown(cell, `table-header-${patternIdx}-${cellIdx}`, footnotes, emojiInfos)}
</th>
))}
</tr>
@ -1948,7 +1958,7 @@ function parseMarkdownContent( @@ -1948,7 +1958,7 @@ function parseMarkdownContent(
key={`td-${patternIdx}-${rowIdx}-${cellIdx}`}
className="border border-gray-300 dark:border-gray-700 px-4 py-2"
>
{parseInlineMarkdown(cell, `table-cell-${patternIdx}-${rowIdx}-${cellIdx}`, footnotes)}
{parseInlineMarkdown(cell, `table-cell-${patternIdx}-${rowIdx}-${cellIdx}`, footnotes, emojiInfos)}
</td>
))}
</tr>
@ -1987,7 +1997,7 @@ function parseMarkdownContent( @@ -1987,7 +1997,7 @@ function parseMarkdownContent(
// Join paragraph lines with newlines to preserve line breaks (especially before em-dashes)
// This preserves the original formatting of the blockquote
const paragraphText = paragraphLines.join('\n')
const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes)
const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos)
return (
<p key={`blockquote-${patternIdx}-para-${paraIdx}`} className="mb-1 last:mb-0 whitespace-pre-line">
@ -2010,7 +2020,7 @@ function parseMarkdownContent( @@ -2010,7 +2020,7 @@ function parseMarkdownContent(
// Each line should have the > prefix preserved
const greentextContent = lines.map((line: string, lineIdx: number) => {
// Parse inline markdown for each line (for links, hashtags, etc.)
const lineContent = parseInlineMarkdown(line, `greentext-${patternIdx}-line-${lineIdx}`, footnotes)
const lineContent = parseInlineMarkdown(line, `greentext-${patternIdx}-line-${lineIdx}`, footnotes, emojiInfos)
return (
<React.Fragment key={`greentext-${patternIdx}-line-${lineIdx}`}>
{lineIdx > 0 && <br />}
@ -2256,7 +2266,7 @@ function parseMarkdownContent( @@ -2256,7 +2266,7 @@ function parseMarkdownContent(
normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ')
normalizedPara = normalizedPara.trim()
if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes)
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes, emojiInfos)
parts.push(
<p key={`text-end-para-${imgIdx}-${paraIdx}`} className="mb-1 last:mb-0">
{paraContent}
@ -2323,7 +2333,7 @@ function parseMarkdownContent( @@ -2323,7 +2333,7 @@ function parseMarkdownContent(
normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ')
normalizedPara = normalizedPara.trim()
if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes)
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes, emojiInfos)
parts.push(
<p key={`text-end-final-para-${paraIdx}`} className="mb-1 last:mb-0">
{paraContent}
@ -2342,7 +2352,7 @@ function parseMarkdownContent( @@ -2342,7 +2352,7 @@ function parseMarkdownContent(
normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ')
normalizedPara = normalizedPara.trim()
if (normalizedPara) {
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes)
const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes, emojiInfos)
parts.push(
<p key={`text-end-para-${paraIdx}`} className="mb-1 last:mb-0">
{paraContent}
@ -2365,7 +2375,7 @@ function parseMarkdownContent( @@ -2365,7 +2375,7 @@ function parseMarkdownContent(
normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ')
normalizedPara = normalizedPara.trim()
if (!normalizedPara) return null
const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes)
const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes, emojiInfos)
return (
<p key={`text-only-para-${paraIdx}`} className="mb-1 last:mb-0">
{paraContent}
@ -2485,7 +2495,7 @@ function parseMarkdownContent( @@ -2485,7 +2495,7 @@ function parseMarkdownContent(
const originalLine = listItemOriginalLines.get(patternIndex)
if (originalLine) {
// Render the original line with inline markdown processing
const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes)
const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes, emojiInfos)
wrappedParts.push(
<span key={`list-item-content-${partIdx}`}>
{lineContent}
@ -2532,7 +2542,7 @@ function parseMarkdownContent( @@ -2532,7 +2542,7 @@ function parseMarkdownContent(
className="text-sm text-gray-700 dark:text-gray-300"
>
<span className="font-semibold">[{id}]:</span>{' '}
<span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes)}</span>
<span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos)}</span>
{' '}
<a
href={`#footnote-ref-${id}`}
@ -2655,7 +2665,7 @@ function parseMarkdownContent( @@ -2655,7 +2665,7 @@ function parseMarkdownContent(
* - Inline code: ``code`` (double backtick) or `code` (single backtick)
* - Footnote references: [^1] (handled at block level, but parsed here for inline context)
*/
function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<string, string> = new Map()): React.ReactNode[] {
function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<string, string> = new Map(), emojiInfos: TEmoji[] = []): React.ReactNode[] {
// Normalize newlines to spaces at the start (defensive - text should already be normalized, but ensure it)
// This prevents any hard breaks within inline content
text = text.replace(/\n/g, ' ')
@ -2963,6 +2973,26 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st @@ -2963,6 +2973,26 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
}
}
})
// Emoji shortcodes :shortcode: or :short code: (custom and native)
const emojiMatches = Array.from(text.matchAll(EMOJI_SHORT_CODE_REGEX))
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') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'emoji',
data: match[0].slice(1, -1).trim()
})
}
}
})
// Sort by index
inlinePatterns.sort((a, b) => a.index - b.index)
@ -3015,11 +3045,11 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st @@ -3015,11 +3045,11 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
if (url.startsWith('payto://')) {
parts.push(
<PaytoLink key={`${keyPrefix}-payto-link-${i}`} paytoUri={url} className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words">
{parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes)}
{parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos)}
</PaytoLink>
)
} else {
const linkContent = parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes)
const linkContent = parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos)
parts.push(
<a
key={`${keyPrefix}-link-${i}`}
@ -3083,6 +3113,19 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st @@ -3083,6 +3113,19 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
/>
)
} else if (pattern.type === 'emoji') {
const shortcode = pattern.data as string
const custom = emojiInfos.find((e) => e.shortcode === shortcode)
if (custom) {
parts.push(<Emoji key={`${keyPrefix}-emoji-${i}`} emoji={custom} classNames={{ img: 'size-4 inline-block' }} />)
} else {
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) {
parts.push(<Emoji key={`${keyPrefix}-emoji-${i}`} emoji={native.emoji} classNames={{ img: 'size-4' }} />)
} else {
parts.push(<span key={`${keyPrefix}-emoji-${i}`}>{`:${shortcode}:`}</span>)
}
}
}
lastIndex = pattern.end
@ -3457,6 +3500,8 @@ export default function MarkdownArticle({ @@ -3457,6 +3500,8 @@ export default function MarkdownArticle({
return map
}, [event.id, JSON.stringify(event.tags), getImageIdentifier])
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event.tags])
// Parse markdown content with post-processing for nostr: links and hashtags
const { nodes: parsedContent, hashtagsInContent } = useMemo(() => {
const result = parseMarkdownContent(preprocessedContent, {
@ -3467,11 +3512,12 @@ export default function MarkdownArticle({ @@ -3467,11 +3512,12 @@ export default function MarkdownArticle({
navigateToRelay,
videoPosterMap,
imageThumbnailMap,
getImageIdentifier
getImageIdentifier,
emojiInfos
})
// Return nodes and hashtags (footnotes are already included in nodes)
return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent }
}, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap, imageThumbnailMap, getImageIdentifier])
}, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap, imageThumbnailMap, getImageIdentifier, emojiInfos])
// Filter metadata tags to only show what's not already in content
const leftoverMetadataTags = useMemo(() => {

50
src/components/NotificationList/index.tsx

@ -22,16 +22,21 @@ import { @@ -22,16 +22,21 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import Tabs from '../Tabs'
import { NotificationItem } from './NotificationItem'
import { NotificationSkeleton } from './NotificationItem/Notification'
import { isTouchDevice } from '@/lib/utils'
import { RefreshButton } from '../RefreshButton'
const LIMIT = 100
const SHOW_COUNT = 30
const NotificationList = forwardRef((_, ref) => {
const NotificationList = forwardRef(
(
{
notificationType
}: {
notificationType: TNotificationType
},
ref
) => {
const { t } = useTranslation()
const { current, display } = usePrimaryPage()
const active = useMemo(() => current === 'notifications' && display, [current, display])
@ -39,7 +44,6 @@ const NotificationList = forwardRef((_, ref) => { @@ -39,7 +44,6 @@ const NotificationList = forwardRef((_, ref) => {
const { getNotificationsSeenAt } = useNotification()
const { notificationListStyle } = useUserPreferences()
const { favoriteRelays } = useFavoriteRelays()
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const [lastReadTime, setLastReadTime] = useState(0)
const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
@ -92,17 +96,10 @@ const NotificationList = forwardRef((_, ref) => { @@ -92,17 +96,10 @@ const NotificationList = forwardRef((_, ref) => {
[loading]
)
// Listen for tab restoration from PageManager
// Reset visible count when tab changes (parent owns tab state)
useEffect(() => {
const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => {
if (e.detail.page === 'notifications' && e.detail.tab) {
setNotificationType(e.detail.tab as TNotificationType)
setShowCount(SHOW_COUNT)
}
}
window.addEventListener('restorePageTab', handleRestore as EventListener)
return () => window.removeEventListener('restorePageTab', handleRestore as EventListener)
}, [])
setShowCount(SHOW_COUNT)
}, [notificationType])
const handleNewEvent = useCallback(
(event: NostrEvent) => {
@ -318,25 +315,7 @@ const NotificationList = forwardRef((_, ref) => { @@ -318,25 +315,7 @@ const NotificationList = forwardRef((_, ref) => {
return (
<div>
<Tabs
value={notificationType}
tabs={[
{ value: 'all', label: 'All' },
{ value: 'mentions', label: 'Mentions' },
{ value: 'reactions', label: 'Reactions' },
{ value: 'zaps', label: 'Zaps' }
]}
onTabChange={(type) => {
setShowCount(SHOW_COUNT)
setNotificationType(type as TNotificationType)
// Dispatch tab change event for PageManager
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'notifications', tab: type }
}))
}}
options={!supportTouch ? <RefreshButton onClick={() => refresh()} /> : null}
/>
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
<div ref={topRef} />
{supportTouch ? (
<PullToRefresh
onRefresh={async () => {
@ -352,6 +331,7 @@ const NotificationList = forwardRef((_, ref) => { @@ -352,6 +331,7 @@ const NotificationList = forwardRef((_, ref) => {
)}
</div>
)
})
}
)
NotificationList.displayName = 'NotificationList'
export default NotificationList

3
src/constants.ts

@ -228,7 +228,8 @@ export const URL_REGEX = @@ -228,7 +228,8 @@ export const URL_REGEX =
export const WS_URL_REGEX =
/wss?:\/\/[\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*]+[^\s.,;:'")\]}!?"']/giu
export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
export const EMOJI_SHORT_CODE_REGEX = /:[a-zA-Z0-9_-]+:/g
/** Matches :shortcode: or :short code: (allows letters, digits, underscore, hyphen, space) */
export const EMOJI_SHORT_CODE_REGEX = /:[a-zA-Z0-9_\-\s]+:/g
export const EMBEDDED_EVENT_REGEX = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
export const EMBEDDED_MENTION_REGEX = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
export const HASHTAG_REGEX = /#[a-zA-Z0-9_\-\u00C0-\u017F\u0100-\u017F\u0180-\u024F\u1E00-\u1EFF]+/g

16
src/lib/nostr-parser.tsx

@ -261,7 +261,9 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo @@ -261,7 +261,9 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
if (type === 'nostr') {
const bech32Id = match[1]
const nostrType = getNostrType(bech32Id)
// Store content without leading whitespace/newline (regex may capture \n or space before "nostr:")
const nostrContent = `nostr:${bech32Id}`
// Add spacing around handles if they're not at the beginning or end of a line
const isAtStart = start === 0 || content[start - 1] === '\n'
const isAtEnd = end === content.length || content[end] === '\n'
@ -277,7 +279,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo @@ -277,7 +279,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
elements.push({
type: 'nostr',
content: match[0],
content: nostrContent,
bech32Id,
nostrType: nostrType || undefined
})
@ -288,7 +290,15 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo @@ -288,7 +290,15 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
content: ' '
})
}
// If nostr is at the start of a line and immediately followed by a single newline, skip it
// so we don't render an errant hard-return behind the address
if (isAtStart && content[end] === '\n') {
lastIndex = end + 1
} else {
lastIndex = end
}
continue
} else if (['image', 'video', 'audio'].includes(type) && url) {
elements.push({
type: type as 'image' | 'video' | 'audio',

14
src/pages/primary/DiscussionsPage/ThreadCard.tsx

@ -2,7 +2,10 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card' @@ -2,7 +2,10 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Clock, Hash, Users } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { formatDistanceToNow } from 'date-fns'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { DISCUSSION_TOPICS } from './discussionTopics'
@ -54,14 +57,13 @@ export default function ThreadCard({ @@ -54,14 +57,13 @@ export default function ThreadCard({
// Get all topics from this thread
const allTopics = extractAllTopics(thread)
// Format creation time
const createdAt = new Date(thread.created_at * 1000)
const timeAgo = formatDistanceToNow(createdAt, { addSuffix: true })
// Format creation time (fromNow() includes suffix e.g. "3 hours ago")
const timeAgo = dayjs.unix(thread.created_at).fromNow()
// Format last activity times
const formatLastActivity = (timestamp: number) => {
if (timestamp === 0) return null
return formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true })
return dayjs.unix(timestamp).fromNow()
}
const lastCommentAgo = formatLastActivity(lastCommentTime)

2
src/pages/primary/DiscussionsPage/index.tsx

@ -1042,7 +1042,7 @@ const DiscussionsPage = forwardRef((_, ref) => { @@ -1042,7 +1042,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
titlebar={<DiscussionsPageTitlebar />}
displayScrollToTopButton
>
<div className="flex flex-col gap-4 p-4">
<div className="min-w-0 pt-2 flex flex-col gap-4 p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<button
onClick={() => setShowCreateDialog(true)}

4
src/pages/primary/MePage/index.tsx

@ -39,7 +39,7 @@ const MePage = forwardRef((_, ref) => { @@ -39,7 +39,7 @@ const MePage = forwardRef((_, ref) => {
titlebar={<MePageTitlebar />}
hideTitlebarBottomBorder
>
<div className="flex flex-col p-4 gap-4 overflow-auto">
<div className="min-w-0 pt-2 flex flex-col p-4 gap-4 overflow-auto">
<AccountManager />
</div>
</PrimaryPageLayout>
@ -53,6 +53,7 @@ const MePage = forwardRef((_, ref) => { @@ -53,6 +53,7 @@ const MePage = forwardRef((_, ref) => {
titlebar={<MePageTitlebar />}
hideTitlebarBottomBorder
>
<div className="min-w-0 pt-2">
<div className="flex gap-4 items-center p-4">
<SimpleUserAvatar userId={pubkey} size="big" />
<div className="space-y-1 flex-1 w-0">
@ -94,6 +95,7 @@ const MePage = forwardRef((_, ref) => { @@ -94,6 +95,7 @@ const MePage = forwardRef((_, ref) => {
</div>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</div>
</PrimaryPageLayout>
)
})

9
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -3,9 +3,13 @@ import { checkAlgoRelay } from '@/lib/relay' @@ -3,9 +3,13 @@ import { checkAlgoRelay } from '@/lib/relay'
import logger from '@/lib/logger'
import { useFeed } from '@/providers/FeedProvider'
import relayInfoService from '@/services/relay-info.service'
import { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
export default function RelaysFeed() {
export default function RelaysFeed({
setSubHeader
}: {
setSubHeader?: (node: React.ReactNode) => void
}) {
logger.debug('RelaysFeed component rendering')
const { feedInfo, relayUrls } = useFeed()
const [isReady, setIsReady] = useState(false)
@ -55,6 +59,7 @@ export default function RelaysFeed() { @@ -55,6 +59,7 @@ export default function RelaysFeed() {
areAlgoRelays={areAlgoRelays}
isMainFeed
showRelayCloseReason
setSubHeader={setSubHeader}
/>
)
}

19
src/pages/primary/NoteListPage/index.tsx

@ -9,7 +9,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -9,7 +9,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TPageRef } from '@/types'
import { Info, Rss } from 'lucide-react'
import {
import React, {
Dispatch,
forwardRef,
SetStateAction,
@ -36,8 +36,16 @@ const NoteListPage = forwardRef((_, ref) => { @@ -36,8 +36,16 @@ const NoteListPage = forwardRef((_, ref) => {
const { pubkey, checkLogin } = useNostr()
const { feedInfo, relayUrls, isReady } = useFeed()
const [showRelayDetails, setShowRelayDetails] = useState(false)
const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null)
useImperativeHandle(ref, () => layoutRef.current)
// Clear subHeader when switching away from relay/relays/all-favorites feed
useEffect(() => {
const isRelaysFeed =
feedInfo.feedType === 'relay' || feedInfo.feedType === 'relays' || feedInfo.feedType === 'all-favorites'
if (!isRelaysFeed) setHomeSubHeader(null)
}, [feedInfo.feedType])
// REMOVED: Scroll-to-top logic - feed should NEVER scroll to top when drawer opens/closes
// The feed stays mounted and maintains scroll position at all times
@ -89,7 +97,7 @@ const NoteListPage = forwardRef((_, ref) => { @@ -89,7 +97,7 @@ const NoteListPage = forwardRef((_, ref) => {
{showRelayDetails && feedInfo.feedType === 'relay' && !!feedInfo.id && (
<RelayInfo url={feedInfo.id!} className="mb-2 pt-3" />
)}
<RelaysFeed />
<RelaysFeed setSubHeader={setHomeSubHeader} />
</>
)
}
@ -107,10 +115,13 @@ const NoteListPage = forwardRef((_, ref) => { @@ -107,10 +115,13 @@ const NoteListPage = forwardRef((_, ref) => {
}
/>
}
subHeader={homeSubHeader ?? undefined}
displayScrollToTopButton
>
<VersionUpdateBanner />
{content}
<div className="min-w-0 pt-2">
<VersionUpdateBanner />
{content}
</div>
</PrimaryPageLayout>
)
})

46
src/pages/primary/NotificationListPage/index.tsx

@ -1,15 +1,22 @@ @@ -1,15 +1,22 @@
import HideUntrustedContentButton from '@/components/HideUntrustedContentButton'
import NotificationList from '@/components/NotificationList'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { RefreshButton } from '@/components/RefreshButton'
import Tabs from '@/components/Tabs'
import { usePrimaryPage } from '@/PageManager'
import { TNotificationType } from '@/types'
import { isTouchDevice } from '@/lib/utils'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { Bell } from 'lucide-react'
import { forwardRef, useEffect, useRef } from 'react'
import { forwardRef, useEffect, useRef, useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
const NotificationListPage = forwardRef((_, ref) => {
const { t } = useTranslation()
const { current } = usePrimaryPage()
const firstRenderRef = useRef(true)
const notificationListRef = useRef<{ refresh: () => void }>(null)
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const supportTouch = useMemo(() => isTouchDevice(), [])
useEffect(() => {
if (current === 'notifications' && !firstRenderRef.current) {
@ -18,14 +25,47 @@ const NotificationListPage = forwardRef((_, ref) => { @@ -18,14 +25,47 @@ const NotificationListPage = forwardRef((_, ref) => {
firstRenderRef.current = false
}, [current])
useEffect(() => {
const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => {
if (e.detail.page === 'notifications' && e.detail.tab) {
setNotificationType(e.detail.tab as TNotificationType)
}
}
window.addEventListener('restorePageTab', handleRestore as EventListener)
return () => window.removeEventListener('restorePageTab', handleRestore as EventListener)
}, [])
return (
<PrimaryPageLayout
ref={ref}
pageName="notifications"
titlebar={<NotificationListPageTitlebar />}
subHeader={
<Tabs
value={notificationType}
tabs={[
{ value: 'all', label: t('All') },
{ value: 'mentions', label: t('Mentions') },
{ value: 'reactions', label: t('Reactions') },
{ value: 'zaps', label: t('Zaps') }
]}
onTabChange={(tab) => {
setNotificationType(tab as TNotificationType)
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'notifications', tab }
}))
}}
options={!supportTouch ? <RefreshButton onClick={() => notificationListRef.current?.refresh()} /> : null}
/>
}
displayScrollToTopButton
>
<NotificationList ref={notificationListRef} />
<div className="min-w-0 pt-2">
<NotificationList
ref={notificationListRef}
notificationType={notificationType}
/>
</div>
</PrimaryPageLayout>
)
})

4
src/pages/primary/ProfilePage/index.tsx

@ -15,7 +15,9 @@ const ProfilePage = forwardRef((_, ref) => { @@ -15,7 +15,9 @@ const ProfilePage = forwardRef((_, ref) => {
displayScrollToTopButton
ref={ref}
>
<Profile id={pubkey ?? undefined} />
<div className="min-w-0 pt-2">
<Profile id={pubkey ?? undefined} />
</div>
</PrimaryPageLayout>
)
})

4
src/pages/primary/RelayPage/index.tsx

@ -14,7 +14,9 @@ const RelayPage = forwardRef(({ url }: { url?: string }, ref) => { @@ -14,7 +14,9 @@ const RelayPage = forwardRef(({ url }: { url?: string }, ref) => {
displayScrollToTopButton
ref={ref}
>
<Relay url={normalizedUrl} />
<div className="min-w-0 pt-2">
<Relay url={normalizedUrl} />
</div>
</PrimaryPageLayout>
)
})

2
src/pages/primary/SearchPage/index.tsx

@ -44,7 +44,7 @@ const SearchPage = forwardRef((_, ref) => { @@ -44,7 +44,7 @@ const SearchPage = forwardRef((_, ref) => {
titlebar={null}
displayScrollToTopButton
>
<div className="px-4 pt-4">
<div className="min-w-0 pt-4 px-4 pb-4">
<div className="text-2xl font-bold mb-4">Search Nostr</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative order-2 sm:order-1">

Loading…
Cancel
Save