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 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"dataloader": "^2.2.3", "dataloader": "^2.2.3",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0", "embla-carousel-wheel-gestures": "^8.1.0",
@ -84,11 +83,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.2.0", "react-i18next": "^15.2.0",
"react-katex": "^3.0.1",
"react-simple-pull-to-refresh": "^1.3.3", "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", "sonner": "^2.0.5",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

9
src/components/Content/index.tsx

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

9
src/components/ContentPreview/Content.tsx

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

153
src/components/NormalFeed/index.tsx

@ -5,7 +5,7 @@ import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types' 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 { useTranslation } from 'react-i18next'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton' import { RefreshButton } from '../RefreshButton'
@ -21,11 +21,14 @@ const NormalFeed = forwardRef<TNoteListRef, {
areAlgoRelays?: boolean areAlgoRelays?: boolean
isMainFeed?: boolean isMainFeed?: boolean
showRelayCloseReason?: 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({ }>(function NormalFeed({
subRequests, subRequests,
areAlgoRelays = false, areAlgoRelays = false,
isMainFeed = false, isMainFeed = false,
showRelayCloseReason = false showRelayCloseReason = false,
setSubHeader
}, ref) { }, ref) {
logger.debug('NormalFeed component rendering with:', { subRequests, areAlgoRelays, isMainFeed }) logger.debug('NormalFeed component rendering with:', { subRequests, areAlgoRelays, isMainFeed })
const { t } = useTranslation() const { t } = useTranslation()
@ -186,81 +189,83 @@ const NormalFeed = forwardRef<TNoteListRef, {
// Determine current tab value // Determine current tab value
const currentTabValue = activeTab const currentTabValue = activeTab
return ( const tabsElement = (
<> <Tabs
<Tabs value={currentTabValue}
value={currentTabValue} tabs={tabs}
tabs={tabs} onTabChange={(tab) => {
onTabChange={(tab) => { handleListModeChange(tab)
handleListModeChange(tab) }}
}} options={
options={ <>
<> {activeTab === 'rss' && showRssFeed && (
{activeTab === 'rss' && showRssFeed && ( <Button
<Button variant="ghost"
variant="ghost" size="titlebar-icon"
size="titlebar-icon" onClick={() => {
onClick={() => { window.dispatchEvent(new CustomEvent('toggleRssFilters'))
window.dispatchEvent(new CustomEvent('toggleRssFilters')) }}
}} title={t('Toggle filters')}
title={t('Toggle filters')} >
> <Search className="h-4 w-4" />
<Search className="h-4 w-4" /> </Button>
</Button> )}
)} <RefreshButton onClick={() => {
<RefreshButton onClick={() => { if (activeTab === 'rss') {
if (activeTab === 'rss') { let feedUrls: string[] = []
// Refresh RSS feeds if (pubkey && rssFeedListEvent) {
// Get feed URLs from event or use default try {
let feedUrls: string[] = [] const urls = rssFeedListEvent.tags
if (pubkey && rssFeedListEvent) { .filter(tag => tag[0] === 'u' && tag[1])
// User has an event - use only feeds from that event (even if empty) .map(tag => tag[1] as string)
try { .filter((url): url is string => {
const urls = rssFeedListEvent.tags if (typeof url !== 'string') return false
.filter(tag => tag[0] === 'u' && tag[1]) const trimmed = url.trim()
.map(tag => tag[1] as string) return trimmed.length > 0
.filter((url): url is string => { })
if (typeof url !== 'string') return false feedUrls = urls
const trimmed = url.trim() } catch (e) {
return trimmed.length > 0 feedUrls = []
})
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
} }
// 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 { } else {
// Refresh Notes/Replies feedUrls = DEFAULT_RSS_FEEDS
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh()
}
} }
}} /> logger.info('[NormalFeed] Manual refresh: triggering RSS background refresh', { feedCount: feedUrls.length })
{activeTab !== 'rss' && ( rssFeedService.backgroundRefreshFeeds(feedUrls).catch(err => {
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} /> 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"> <div className="pt-2 min-w-0">
{activeTab === 'rss' ? ( {activeTab === 'rss' ? (
<RssFeedList key={rssRefreshKey} /> <RssFeedList key={rssRefreshKey} />

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

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

50
src/components/NotificationList/index.tsx

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

3
src/constants.ts

@ -228,7 +228,8 @@ export const URL_REGEX =
export const WS_URL_REGEX = export const WS_URL_REGEX =
/wss?:\/\/[\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*]+[^\s.,;:'")\]}!?"']/giu /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 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_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 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 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
if (type === 'nostr') { if (type === 'nostr') {
const bech32Id = match[1] const bech32Id = match[1]
const nostrType = getNostrType(bech32Id) 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 // 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 isAtStart = start === 0 || content[start - 1] === '\n'
const isAtEnd = end === content.length || content[end] === '\n' const isAtEnd = end === content.length || content[end] === '\n'
@ -277,7 +279,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
elements.push({ elements.push({
type: 'nostr', type: 'nostr',
content: match[0], content: nostrContent,
bech32Id, bech32Id,
nostrType: nostrType || undefined nostrType: nostrType || undefined
}) })
@ -288,7 +290,15 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
content: ' ' 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) { } else if (['image', 'video', 'audio'].includes(type) && url) {
elements.push({ elements.push({
type: type as 'image' | 'video' | 'audio', 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'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Clock, Hash, Users } from 'lucide-react' import { Clock, Hash, Users } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' 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 { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { DISCUSSION_TOPICS } from './discussionTopics' import { DISCUSSION_TOPICS } from './discussionTopics'
@ -54,14 +57,13 @@ export default function ThreadCard({
// Get all topics from this thread // Get all topics from this thread
const allTopics = extractAllTopics(thread) const allTopics = extractAllTopics(thread)
// Format creation time // Format creation time (fromNow() includes suffix e.g. "3 hours ago")
const createdAt = new Date(thread.created_at * 1000) const timeAgo = dayjs.unix(thread.created_at).fromNow()
const timeAgo = formatDistanceToNow(createdAt, { addSuffix: true })
// Format last activity times // Format last activity times
const formatLastActivity = (timestamp: number) => { const formatLastActivity = (timestamp: number) => {
if (timestamp === 0) return null if (timestamp === 0) return null
return formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true }) return dayjs.unix(timestamp).fromNow()
} }
const lastCommentAgo = formatLastActivity(lastCommentTime) const lastCommentAgo = formatLastActivity(lastCommentTime)

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

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

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

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

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

@ -3,9 +3,13 @@ import { checkAlgoRelay } from '@/lib/relay'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import relayInfoService from '@/services/relay-info.service' 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') logger.debug('RelaysFeed component rendering')
const { feedInfo, relayUrls } = useFeed() const { feedInfo, relayUrls } = useFeed()
const [isReady, setIsReady] = useState(false) const [isReady, setIsReady] = useState(false)
@ -55,6 +59,7 @@ export default function RelaysFeed() {
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
isMainFeed isMainFeed
showRelayCloseReason showRelayCloseReason
setSubHeader={setSubHeader}
/> />
) )
} }

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

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

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

@ -1,15 +1,22 @@
import HideUntrustedContentButton from '@/components/HideUntrustedContentButton' import HideUntrustedContentButton from '@/components/HideUntrustedContentButton'
import NotificationList from '@/components/NotificationList' 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 { usePrimaryPage } from '@/PageManager'
import { TNotificationType } from '@/types'
import { isTouchDevice } from '@/lib/utils'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { Bell } from 'lucide-react' import { Bell } from 'lucide-react'
import { forwardRef, useEffect, useRef } from 'react' import { forwardRef, useEffect, useRef, useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const NotificationListPage = forwardRef((_, ref) => { const NotificationListPage = forwardRef((_, ref) => {
const { t } = useTranslation()
const { current } = usePrimaryPage() const { current } = usePrimaryPage()
const firstRenderRef = useRef(true) const firstRenderRef = useRef(true)
const notificationListRef = useRef<{ refresh: () => void }>(null) const notificationListRef = useRef<{ refresh: () => void }>(null)
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const supportTouch = useMemo(() => isTouchDevice(), [])
useEffect(() => { useEffect(() => {
if (current === 'notifications' && !firstRenderRef.current) { if (current === 'notifications' && !firstRenderRef.current) {
@ -18,14 +25,47 @@ const NotificationListPage = forwardRef((_, ref) => {
firstRenderRef.current = false firstRenderRef.current = false
}, [current]) }, [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 ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={ref}
pageName="notifications" pageName="notifications"
titlebar={<NotificationListPageTitlebar />} 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 displayScrollToTopButton
> >
<NotificationList ref={notificationListRef} /> <div className="min-w-0 pt-2">
<NotificationList
ref={notificationListRef}
notificationType={notificationType}
/>
</div>
</PrimaryPageLayout> </PrimaryPageLayout>
) )
}) })

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

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

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

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

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

@ -44,7 +44,7 @@ const SearchPage = forwardRef((_, ref) => {
titlebar={null} titlebar={null}
displayScrollToTopButton 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="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 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"> <div className="flex-1 relative order-2 sm:order-1">

Loading…
Cancel
Save