Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
5e47a34482
  1. 4
      package-lock.json
  2. 2
      src/PageManager.tsx
  3. 65
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  4. 18
      src/components/Note/Poll.tsx
  5. 20
      src/components/NoteOptions/ReportDialog.tsx
  6. 27
      src/components/NoteOptions/useMenuActions.tsx
  7. 34
      src/components/NoteStats/LikeButton.tsx
  8. 17
      src/components/NoteStats/RepostButton.tsx
  9. 35
      src/components/NoteStats/VoteButtons.tsx
  10. 51
      src/components/RelayStatusDisplay/index.tsx
  11. 19
      src/index.css
  12. 1
      src/lib/publishing-feedback.tsx
  13. 17
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  14. 6
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  15. 154
      src/providers/FeedProvider.tsx
  16. 64
      src/services/client.service.ts
  17. 116
      src/services/relay-selection.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "15.0.0", "version": "16.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "15.0.0", "version": "16.1.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
src/PageManager.tsx

@ -101,7 +101,7 @@ const getPrimaryPageMap = () => ({
// Type for primary page names - use the return type of getPrimaryPageMap // Type for primary page names - use the return type of getPrimaryPageMap
export type TPrimaryPageName = keyof ReturnType<typeof getPrimaryPageMap> export type TPrimaryPageName = keyof ReturnType<typeof getPrimaryPageMap>
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined) export const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined) const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)

65
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -652,28 +652,49 @@ function parseMarkdownContent(
}) })
} }
} }
// Blockquotes (> text or >) // Blockquotes (> text or >) and Greentext (>text with no space)
else if (line.match(/^>\s*/)) { else if (line.match(/^>\s*/)) {
// Collect consecutive blockquote lines // Check if this is greentext: >text with no space after >
// Pattern: > followed immediately by non-whitespace, non-> character
const greentextMatch = line.match(/^>([^\s>].*)$/)
const isGreentext = greentextMatch !== null
// Collect consecutive blockquote/greentext lines
const blockquoteLines: string[] = [] const blockquoteLines: string[] = []
const blockquoteStartIndex = lineStartIndex const blockquoteStartIndex = lineStartIndex
let blockquoteLineIdx = lineIdx let blockquoteLineIdx = lineIdx
let tempIndex = lineStartIndex let tempIndex = lineStartIndex
let allGreentext = isGreentext
while (blockquoteLineIdx < lines.length) { while (blockquoteLineIdx < lines.length) {
const blockquoteLine = lines[blockquoteLineIdx] const blockquoteLine = lines[blockquoteLineIdx]
const lineGreentextMatch = blockquoteLine.match(/^>([^\s>].*)$/)
const lineIsGreentext = lineGreentextMatch !== null
if (blockquoteLine.match(/^>\s*/)) { if (blockquoteLine.match(/^>\s*/)) {
// If we started with greentext, only continue if this line is also greentext
// If we started with regular blockquote, only continue if this line is also regular blockquote
if (isGreentext && !lineIsGreentext) {
break
}
if (!isGreentext && lineIsGreentext) {
break
}
// Strip the > prefix and optional space // Strip the > prefix and optional space
const content = blockquoteLine.replace(/^>\s?/, '') const content = blockquoteLine.replace(/^>\s?/, '')
blockquoteLines.push(content) blockquoteLines.push(content)
blockquoteLineIdx++ blockquoteLineIdx++
tempIndex += blockquoteLine.length + 1 // +1 for newline tempIndex += blockquoteLine.length + 1 // +1 for newline
// Update allGreentext flag (all lines must be greentext for it to be a greentext block)
allGreentext = allGreentext && lineIsGreentext
} else if (blockquoteLine.trim() === '') { } else if (blockquoteLine.trim() === '') {
// Empty line without > - this ALWAYS ends the blockquote // Empty line without > - this ALWAYS ends the blockquote/greentext
// Even if the next line is another blockquote, we want separate blockquotes // Even if the next line is another blockquote, we want separate blockquotes
break break
} else { } else {
// Non-empty line that doesn't start with > - ends the blockquote // Non-empty line that doesn't start with > - ends the blockquote/greentext
break break
} }
} }
@ -693,10 +714,13 @@ function parseMarkdownContent(
// Calculate end index: tempIndex - 1 (subtract 1 because we don't want the trailing newline) // Calculate end index: tempIndex - 1 (subtract 1 because we don't want the trailing newline)
const blockquoteEndIndex = tempIndex - 1 const blockquoteEndIndex = tempIndex - 1
// Use greentext type if all lines are greentext, otherwise use blockquote
const patternType = allGreentext ? 'greentext' : 'blockquote'
blockPatterns.push({ blockPatterns.push({
index: blockquoteStartIndex, index: blockquoteStartIndex,
end: blockquoteEndIndex, end: blockquoteEndIndex,
type: 'blockquote', type: patternType,
data: { lines: blockquoteLines, lineNum: lineIdx } data: { lines: blockquoteLines, lineNum: lineIdx }
}) })
// Update currentIndex to position at the start of the line after the blockquote // Update currentIndex to position at the start of the line after the blockquote
@ -1165,9 +1189,9 @@ function parseMarkdownContent(
patterns.sort((a, b) => a.index - b.index) patterns.sort((a, b) => a.index - b.index)
// Remove overlapping patterns (keep the first one) // Remove overlapping patterns (keep the first one)
// Block-level patterns (headers, lists, horizontal rules, tables, blockquotes, code blocks) take priority // Block-level patterns (headers, lists, horizontal rules, tables, blockquotes, greentext, code blocks) take priority
const filteredPatterns: typeof patterns = [] const filteredPatterns: typeof patterns = []
const blockLevelTypes = ['header', 'horizontal-rule', 'bullet-list-item', 'numbered-list-item', 'table', 'blockquote', 'footnote-definition', 'fenced-code-block'] const blockLevelTypes = ['header', 'horizontal-rule', 'bullet-list-item', 'numbered-list-item', 'table', 'blockquote', 'greentext', 'footnote-definition', 'fenced-code-block']
const blockLevelPatternsFromAll = patterns.filter(p => blockLevelTypes.includes(p.type)) const blockLevelPatternsFromAll = patterns.filter(p => blockLevelTypes.includes(p.type))
const otherPatterns = patterns.filter(p => !blockLevelTypes.includes(p.type)) const otherPatterns = patterns.filter(p => !blockLevelTypes.includes(p.type))
@ -1221,7 +1245,9 @@ function parseMarkdownContent(
pattern.type !== 'numbered-list-item' && pattern.type !== 'numbered-list-item' &&
pattern.type !== 'table' && pattern.type !== 'table' &&
pattern.type !== 'blockquote' && pattern.type !== 'blockquote' &&
pattern.type !== 'footnote-definition') { pattern.type !== 'greentext' &&
pattern.type !== 'footnote-definition' &&
pattern.type !== 'fenced-code-block') {
// This pattern was already processed as part of merged text // This pattern was already processed as part of merged text
// Skip it to avoid duplicate rendering // Skip it to avoid duplicate rendering
return return
@ -1973,6 +1999,29 @@ function parseMarkdownContent(
{blockquoteContent} {blockquoteContent}
</blockquote> </blockquote>
) )
} else if (pattern.type === 'greentext') {
const { lines } = pattern.data
// Join all greentext lines with <br> to preserve line breaks
// 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)
return (
<React.Fragment key={`greentext-${patternIdx}-line-${lineIdx}`}>
{lineIdx > 0 && <br />}
&gt;{lineContent}
</React.Fragment>
)
})
parts.push(
<span
key={`greentext-${patternIdx}`}
className="greentext block my-1"
>
{greentextContent}
</span>
)
} else if (pattern.type === 'fenced-code-block') { } else if (pattern.type === 'fenced-code-block') {
const { code, language } = pattern.data const { code, language } = pattern.data
// Render code block with syntax highlighting // Render code block with syntax highlighting

18
src/components/Note/Poll.tsx

@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
export default function Poll({ event, className }: { event: Event; className?: string }) { export default function Poll({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -119,10 +120,25 @@ export default function Poll({ event, className }: { event: Event; className?: s
const additionalRelayUrls = await ensurePollRelays(event.pubkey, poll) const additionalRelayUrls = await ensurePollRelays(event.pubkey, poll)
const draftEvent = createPollResponseDraftEvent(event, selectedOptionIds) const draftEvent = createPollResponseDraftEvent(event, selectedOptionIds)
await publish(draftEvent, { const publishedEvent = await publish(draftEvent, {
additionalRelayUrls additionalRelayUrls
}) })
// Show publishing feedback
if ((publishedEvent as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (publishedEvent as any).relayStatuses,
successCount: (publishedEvent as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (publishedEvent as any).relayStatuses.length
}, {
message: t('Vote published'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Vote published'))
}
setSelectedOptionIds([]) setSelectedOptionIds([])
pollResultsService.addPollResponse(event.id, pubkey, selectedOptionIds) pollResultsService.addPollResponse(event.id, pubkey, selectedOptionIds)
} catch (error) { } catch (error) {

20
src/components/NoteOptions/ReportDialog.tsx

@ -90,8 +90,24 @@ function ReportContent({ event, closeDialog }: { event: NostrEvent; closeDialog:
try { try {
setReporting(true) setReporting(true)
const draftEvent = createReportDraftEvent(event, reason) const draftEvent = createReportDraftEvent(event, reason)
await publish(draftEvent) const publishedEvent = await publish(draftEvent)
toast.success(t('Successfully report'))
// Show publishing feedback with relay messages
if ((publishedEvent as any)?.relayStatuses) {
const { showPublishingFeedback } = await import('@/lib/publishing-feedback')
showPublishingFeedback({
success: true,
relayStatuses: (publishedEvent as any).relayStatuses,
successCount: (publishedEvent as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (publishedEvent as any).relayStatuses.length
}, {
message: t('Successfully report'),
duration: 4000
})
} else {
toast.success(t('Successfully report'))
}
closeDialog() closeDialog()
} catch (error) { } catch (error) {
toast.error(t('Failed to report') + ': ' + (error as Error).message) toast.error(t('Failed to report') + ': ' + (error as Error).message)

27
src/components/NoteOptions/useMenuActions.tsx

@ -15,11 +15,12 @@ import client from '@/services/client.service'
import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert, Pin, FileDown, Globe, BookOpen, Highlighter } from 'lucide-react' import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert, Pin, FileDown, Globe, BookOpen, Highlighter } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect, useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import { usePrimaryPage } from '@/PageManager' import { PrimaryPageContext } from '@/PageManager'
import { showPublishingFeedback } from '@/lib/publishing-feedback'
export interface SubMenuAction { export interface SubMenuAction {
label: React.ReactNode label: React.ReactNode
@ -57,7 +58,9 @@ export function useMenuActions({
openHighlightEditor openHighlightEditor
}: UseMenuActionsProps) { }: UseMenuActionsProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { current: currentPrimaryPage } = usePrimaryPage() // Use useContext directly to avoid error if provider is not available
const primaryPageContext = useContext(PrimaryPageContext)
const currentPrimaryPage = primaryPageContext?.current ?? null
const { pubkey, attemptDelete, publish } = useNostr() const { pubkey, attemptDelete, publish } = useNostr()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays } = useFavoriteRelays()
@ -182,7 +185,7 @@ export function useMenuActions({
// Create and publish the new pin list event // Create and publish the new pin list event
logger.component('PinNote', 'Publishing new pin list event', { tagCount: newTags.length, relayCount: comprehensiveRelays.length }) logger.component('PinNote', 'Publishing new pin list event', { tagCount: newTags.length, relayCount: comprehensiveRelays.length })
await publish({ const publishedEvent = await publish({
kind: 10001, kind: 10001,
tags: newTags, tags: newTags,
content: '', content: '',
@ -191,9 +194,23 @@ export function useMenuActions({
specifiedRelayUrls: comprehensiveRelays specifiedRelayUrls: comprehensiveRelays
}) })
// Show publishing feedback with relay messages
if ((publishedEvent as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (publishedEvent as any).relayStatuses,
successCount: (publishedEvent as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (publishedEvent as any).relayStatuses.length
}, {
message: successMessage,
duration: 4000
})
} else {
toast.success(successMessage)
}
// Update local state - the publish will update the cache automatically // Update local state - the publish will update the cache automatically
setIsPinned(!isPinned) setIsPinned(!isPinned)
toast.success(successMessage)
closeDrawer() closeDrawer()
} catch (error) { } catch (error) {
logger.component('PinNote', 'Error pinning/unpinning note', { error: (error as Error).message }) logger.component('PinNote', 'Error pinning/unpinning note', { error: (error as Error).message })

34
src/components/NoteStats/LikeButton.tsx

@ -24,6 +24,7 @@ import Emoji from '../Emoji'
import EmojiPicker from '../EmojiPicker' import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis' import SuggestedEmojis from '../SuggestedEmojis'
import { formatCount } from './utils' import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
const DISCUSSION_EMOJIS = ['⬆', '⬇'] const DISCUSSION_EMOJIS = ['⬆', '⬇']
@ -120,13 +121,44 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
if (reactionEvent) { if (reactionEvent) {
// Create and publish a deletion request (kind 5) // Create and publish a deletion request (kind 5)
const deletionRequest = createDeletionRequestDraftEvent(reactionEvent) const deletionRequest = createDeletionRequestDraftEvent(reactionEvent)
await publish(deletionRequest) const deletedEvent = await publish(deletionRequest)
// Show publishing feedback
if ((deletedEvent as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (deletedEvent as any).relayStatuses,
successCount: (deletedEvent as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (deletedEvent as any).relayStatuses.length
}, {
message: t('Reaction removed'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Reaction removed'))
}
} }
} }
} else { } else {
// User is adding a new reaction // User is adding a new reaction
const reaction = createReactionDraftEvent(event, emoji) const reaction = createReactionDraftEvent(event, emoji)
const evt = await publish(reaction) const evt = await publish(reaction)
// Show publishing feedback
if ((evt as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (evt as any).relayStatuses,
successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (evt as any).relayStatuses.length
}, {
message: t('Reaction published'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Reaction published'))
}
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} }
} catch (error) { } catch (error) {

17
src/components/NoteStats/RepostButton.tsx

@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import PostEditor from '../PostEditor' import PostEditor from '../PostEditor'
import { formatCount } from './utils' import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
export default function RepostButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { export default function RepostButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -59,6 +60,22 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
const repost = createRepostDraftEvent(event) const repost = createRepostDraftEvent(event)
const evt = await publish(repost) const evt = await publish(repost)
// Show publishing feedback
if ((evt as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (evt as any).relayStatuses,
successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (evt as any).relayStatuses.length
}, {
message: t('Repost published'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Repost published'))
}
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} catch (error) { } catch (error) {
logger.error('Repost failed', { error, eventId: event.id }) logger.error('Repost failed', { error, eventId: event.id })

35
src/components/NoteStats/VoteButtons.tsx

@ -7,8 +7,11 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { useTranslation } from 'react-i18next'
export default function VoteButtons({ event }: { event: Event }) { export default function VoteButtons({ event }: { event: Event }) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const [voting, setVoting] = useState<string | null>(null) const [voting, setVoting] = useState<string | null>(null)
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
@ -62,6 +65,22 @@ export default function VoteButtons({ event }: { event: Event }) {
// Remove vote by creating a reaction with the same emoji (this will toggle it off) // Remove vote by creating a reaction with the same emoji (this will toggle it off)
const reaction = createReactionDraftEvent(event, emoji) const reaction = createReactionDraftEvent(event, emoji)
const evt = await publish(reaction) const evt = await publish(reaction)
// Show publishing feedback
if ((evt as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (evt as any).relayStatuses,
successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (evt as any).relayStatuses.length
}, {
message: t('Vote removed'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Vote removed'))
}
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} else { } else {
// If user voted the opposite way, first remove the old vote // If user voted the opposite way, first remove the old vote
@ -74,6 +93,22 @@ export default function VoteButtons({ event }: { event: Event }) {
// Then add the new vote // Then add the new vote
const reaction = createReactionDraftEvent(event, emoji) const reaction = createReactionDraftEvent(event, emoji)
const evt = await publish(reaction) const evt = await publish(reaction)
// Show publishing feedback
if ((evt as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (evt as any).relayStatuses,
successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (evt as any).relayStatuses.length
}, {
message: t('Vote published'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Vote published'))
}
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} }
} catch (error) { } catch (error) {

51
src/components/RelayStatusDisplay/index.tsx

@ -1,3 +1,4 @@
import React from 'react'
import { Check, X } from 'lucide-react' import { Check, X } from 'lucide-react'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
@ -44,10 +45,53 @@ function formatRelayError(error: string): string {
return error return error
} }
/**
* Render text with URLs as clickable hyperlinks
*/
function renderTextWithLinks(text: string): React.ReactNode {
// URL regex pattern - matches http://, https://, ws://, wss:// URLs
const urlRegex = /(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)/gi
const parts: React.ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = urlRegex.exec(text)) !== null) {
// Add text before the URL
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index))
}
// Add the URL as a link
const url = match[0]
parts.push(
<a
key={match.index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{url}
</a>
)
lastIndex = match.index + match[0].length
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex))
}
return parts.length > 0 ? <>{parts}</> : text
}
interface RelayStatus { interface RelayStatus {
url: string url: string
success: boolean success: boolean
error?: string error?: string
message?: string
authAttempted?: boolean authAttempted?: boolean
} }
@ -103,7 +147,12 @@ export default function RelayStatusDisplay({
{!status.success && status.error && ( {!status.success && status.error && (
<div className="text-xs text-red-600 dark:text-red-400 break-words"> <div className="text-xs text-red-600 dark:text-red-400 break-words">
{formatRelayError(status.error)} {renderTextWithLinks(formatRelayError(status.error))}
</div>
)}
{status.success && status.message && (
<div className="text-xs text-green-600 dark:text-green-400 break-words">
{renderTextWithLinks(status.message)}
</div> </div>
)} )}
</div> </div>

19
src/index.css

@ -425,3 +425,22 @@
transform: translate(-1px, -1px) rotate(-1deg); transform: translate(-1px, -1px) rotate(-1deg);
} }
} }
/* Greentext styling - 4chan style */
.greentext {
color: #4a7c3a; /* Deeper, darker green for better readability in light mode */
display: block;
margin: 0.25rem 0;
font-family: inherit;
}
.dark .greentext {
color: #8fbc8f; /* Lighter green for dark mode */
}
/* Ensure greentext lines appear on their own line even if markdown processes them */
.markdown-content .greentext,
.prose .greentext {
display: block;
margin: 0.25rem 0;
}

1
src/lib/publishing-feedback.tsx

@ -6,6 +6,7 @@ export type RelayStatus = {
url: string url: string
success: boolean success: boolean
error?: string error?: string
message?: string
authAttempted?: boolean authAttempted?: boolean
} }

17
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -19,7 +19,7 @@ import { useGroupList } from '@/providers/GroupListProvider'
import { TDraftEvent, TRelaySet } from '@/types' import { TDraftEvent, TRelaySet } from '@/types'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { prefixNostrAddresses } from '@/lib/nostr-address' import { prefixNostrAddresses } from '@/lib/nostr-address'
import { showPublishingError } from '@/lib/publishing-feedback' import { showPublishingError, showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
import relaySelectionService from '@/services/relay-selection.service' import relaySelectionService from '@/services/relay-selection.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -460,6 +460,21 @@ export default function CreateThreadDialog({
if (publishedEvent) { if (publishedEvent) {
// Show publishing feedback with relay messages
if ((publishedEvent as any).relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (publishedEvent as any).relayStatuses,
successCount: (publishedEvent as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (publishedEvent as any).relayStatuses.length
}, {
message: t('Thread published'),
duration: 6000
})
} else {
showSimplePublishSuccess(t('Thread published'))
}
onThreadCreated(publishedEvent) onThreadCreated(publishedEvent)
onClose() onClose()
} else { } else {

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

@ -20,6 +20,12 @@ export default function RelaysFeed() {
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
// If relayUrls is empty, we can't initialize the feed
if (relayUrls.length === 0) {
logger.debug('RelaysFeed: relayUrls is empty, not initializing')
setIsReady(false)
return
}
const relayInfos = await relayInfoService.getRelayInfos(relayUrls) const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))) setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)))
setIsReady(true) setIsReady(true)

154
src/providers/FeedProvider.tsx

@ -6,7 +6,7 @@ import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TFeedInfo, TFeedType } from '@/types' import { TFeedInfo, TFeedType } from '@/types'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useRef, useState } from 'react' import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
@ -41,77 +41,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
}) })
const feedInfoRef = useRef<TFeedInfo>(feedInfo) const feedInfoRef = useRef<TFeedInfo>(feedInfo)
useEffect(() => { const switchFeed = useCallback(async (
const init = async () => {
logger.debug('FeedProvider init:', { isInitialized, pubkey })
if (!isInitialized) {
return
}
// Get first visible (non-blocked) favorite relay as default
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
let feedInfo: TFeedInfo = {
feedType: 'relay',
id: visibleRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
}
// Ensure we always have a valid relay ID
if (!feedInfo.id) {
feedInfo.id = DEFAULT_FAVORITE_RELAYS[0]
}
logger.debug('Initial feedInfo setup:', { visibleRelays, favoriteRelays, blockedRelays, feedInfo })
if (pubkey) {
const storedFeedInfo = storage.getFeedInfo(pubkey)
logger.debug('Stored feed info:', storedFeedInfo)
if (storedFeedInfo) {
feedInfo = storedFeedInfo
}
}
if (feedInfo.feedType === 'relays') {
return await switchFeed('relays', { activeRelaySetId: feedInfo.id })
}
if (feedInfo.feedType === 'relay') {
// Check if the stored relay is blocked, if so use first visible relay instead
if (feedInfo.id && blockedRelays.includes(feedInfo.id)) {
logger.component('FeedProvider', 'Stored relay is blocked, using first visible relay instead')
feedInfo.id = visibleRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
}
logger.component('FeedProvider', 'Initial relay setup, calling switchFeed', { relayId: feedInfo.id })
return await switchFeed('relay', { relay: feedInfo.id })
}
// update following feed if pubkey changes
if (feedInfo.feedType === 'following' && pubkey) {
return await switchFeed('following', { pubkey })
}
if (feedInfo.feedType === 'bookmarks' && pubkey) {
return await switchFeed('bookmarks', { pubkey })
}
if (feedInfo.feedType === 'all-favorites') {
logger.debug('Initializing all-favorites feed')
return await switchFeed('all-favorites')
}
}
init()
}, [pubkey, isInitialized])
// Update relay URLs when favoriteRelays change and we're in all-favorites mode
useEffect(() => {
if (feedInfo.feedType === 'all-favorites') {
// Filter out blocked relays
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
logger.debug('Updating relay URLs for all-favorites:', visibleRelays)
setRelayUrls(visibleRelays)
}
}, [favoriteRelays, blockedRelays, feedInfo.feedType])
const switchFeed = async (
feedType: TFeedType, feedType: TFeedType,
options: { options: {
activeRelaySetId?: string | null activeRelaySetId?: string | null
@ -232,7 +162,85 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
return return
} }
setIsReady(true) setIsReady(true)
} }, [pubkey, favoriteRelays, blockedRelays, relaySets])
useEffect(() => {
const init = async () => {
logger.debug('FeedProvider init:', { isInitialized, pubkey, favoriteRelays: favoriteRelays.length, blockedRelays: blockedRelays.length })
if (!isInitialized) {
return
}
// Wait for favoriteRelays to be initialized (should have at least default relays)
// If favoriteRelays is empty, it might not be initialized yet, so wait
if (favoriteRelays.length === 0 && !pubkey) {
// For anonymous users, favoriteRelays should be initialized from BIG_RELAY_URLS
// If it's still empty, something is wrong, but we'll use defaults
logger.debug('FeedProvider: favoriteRelays is empty, using defaults')
}
// Get first visible (non-blocked) favorite relay as default
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
let feedInfo: TFeedInfo = {
feedType: 'relay',
id: visibleRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
}
// Ensure we always have a valid relay ID
if (!feedInfo.id) {
feedInfo.id = DEFAULT_FAVORITE_RELAYS[0]
}
logger.debug('Initial feedInfo setup:', { visibleRelays, favoriteRelays, blockedRelays, feedInfo })
if (pubkey) {
const storedFeedInfo = storage.getFeedInfo(pubkey)
logger.debug('Stored feed info:', storedFeedInfo)
if (storedFeedInfo) {
feedInfo = storedFeedInfo
}
}
if (feedInfo.feedType === 'relays') {
return await switchFeed('relays', { activeRelaySetId: feedInfo.id })
}
if (feedInfo.feedType === 'relay') {
// Check if the stored relay is blocked, if so use first visible relay instead
if (feedInfo.id && blockedRelays.includes(feedInfo.id)) {
logger.component('FeedProvider', 'Stored relay is blocked, using first visible relay instead')
feedInfo.id = visibleRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
}
logger.component('FeedProvider', 'Initial relay setup, calling switchFeed', { relayId: feedInfo.id })
return await switchFeed('relay', { relay: feedInfo.id })
}
// update following feed if pubkey changes
if (feedInfo.feedType === 'following' && pubkey) {
return await switchFeed('following', { pubkey })
}
if (feedInfo.feedType === 'bookmarks' && pubkey) {
return await switchFeed('bookmarks', { pubkey })
}
if (feedInfo.feedType === 'all-favorites') {
logger.debug('Initializing all-favorites feed')
return await switchFeed('all-favorites')
}
}
init()
}, [pubkey, isInitialized, favoriteRelays, blockedRelays, switchFeed])
// Update relay URLs when favoriteRelays change and we're in all-favorites mode
useEffect(() => {
if (feedInfo.feedType === 'all-favorites') {
// Filter out blocked relays
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
logger.debug('Updating relay URLs for all-favorites:', visibleRelays)
setRelayUrls(visibleRelays)
}
}, [pubkey, isInitialized, favoriteRelays, blockedRelays, relaySets])
return ( return (
<FeedContext.Provider <FeedContext.Provider

64
src/services/client.service.ts

@ -90,6 +90,15 @@ class ClientService extends EventTarget {
event: NEvent, event: NEvent,
{ specifiedRelayUrls, additionalRelayUrls }: TPublishOptions = {} { specifiedRelayUrls, additionalRelayUrls }: TPublishOptions = {}
) { ) {
if (event.kind === kinds.RelayList) {
logger.info('[DetermineTargetRelays] Determining target relays for relay list event', {
pubkey: event.pubkey?.substring(0, 8),
hasSpecifiedRelays: !!specifiedRelayUrls?.length,
specifiedRelayCount: specifiedRelayUrls?.length ?? 0,
hasAdditionalRelays: !!additionalRelayUrls?.length,
additionalRelayCount: additionalRelayUrls?.length ?? 0
})
}
// For Report events, always include user's write relays first, then add seen relays if they're write-capable // For Report events, always include user's write relays first, then add seen relays if they're write-capable
if (event.kind === kinds.Report) { if (event.kind === kinds.Report) {
// Start with user's write relays (outboxes) - these are the primary targets for reports // Start with user's write relays (outboxes) - these are the primary targets for reports
@ -160,14 +169,42 @@ class ClientService extends EventTarget {
].includes(event.kind) ].includes(event.kind)
) { ) {
_additionalRelayUrls.push(...BIG_RELAY_URLS, ...PROFILE_RELAY_URLS) _additionalRelayUrls.push(...BIG_RELAY_URLS, ...PROFILE_RELAY_URLS)
logger.debug('[DetermineTargetRelays] Relay list event detected, adding BIG_RELAY_URLS and PROFILE_RELAY_URLS', {
kind: event.kind,
bigRelays: BIG_RELAY_URLS,
profileRelays: PROFILE_RELAY_URLS,
additionalRelayCount: _additionalRelayUrls.length
})
} else if (event.kind === ExtendedKind.RSS_FEED_LIST) { } else if (event.kind === ExtendedKind.RSS_FEED_LIST) {
_additionalRelayUrls.push(...FAST_WRITE_RELAY_URLS, ...PROFILE_RELAY_URLS) _additionalRelayUrls.push(...FAST_WRITE_RELAY_URLS, ...PROFILE_RELAY_URLS)
} }
if (event.kind === kinds.RelayList) {
logger.debug('[DetermineTargetRelays] Fetching user relay list for relay list event publication', {
pubkey: event.pubkey?.substring(0, 8),
kind: event.kind
})
}
const relayList = await this.fetchRelayList(event.pubkey) const relayList = await this.fetchRelayList(event.pubkey)
if (event.kind === kinds.RelayList) {
logger.debug('[DetermineTargetRelays] User relay list fetched', {
hasRelayList: !!relayList,
writeRelayCount: relayList?.write?.length ?? 0,
readRelayCount: relayList?.read?.length ?? 0,
writeRelays: relayList?.write?.slice(0, 10) ?? []
})
}
relays = (relayList?.write.slice(0, 10) ?? []).concat( relays = (relayList?.write.slice(0, 10) ?? []).concat(
Array.from(new Set(_additionalRelayUrls)) ?? [] Array.from(new Set(_additionalRelayUrls)) ?? []
) )
if (event.kind === kinds.RelayList) {
logger.info('[DetermineTargetRelays] Final relay list for relay list event publication', {
totalRelayCount: relays.length,
userWriteRelays: relayList?.write?.slice(0, 10) ?? [],
additionalRelays: Array.from(new Set(_additionalRelayUrls)),
allRelays: relays
})
}
} }
if (!relays.length) { if (!relays.length) {
@ -185,7 +222,15 @@ class ClientService extends EventTarget {
}) })
const uniqueRelayUrls = Array.from(new Set(relayUrls)) const uniqueRelayUrls = Array.from(new Set(relayUrls))
logger.debug('[PublishEvent] Unique relays', { count: uniqueRelayUrls.length, relays: uniqueRelayUrls.slice(0, 5) }) if (event.kind === kinds.RelayList) {
logger.info('[PublishEvent] Publishing relay list event to relays', {
eventId: event.id?.substring(0, 8),
totalRelayCount: uniqueRelayUrls.length,
allRelays: uniqueRelayUrls
})
} else {
logger.debug('[PublishEvent] Unique relays', { count: uniqueRelayUrls.length, relays: uniqueRelayUrls.slice(0, 5) })
}
const relayStatuses: { url: string; success: boolean; error?: string }[] = [] const relayStatuses: { url: string; success: boolean; error?: string }[] = []
@ -1575,13 +1620,30 @@ class ClientService extends EventTarget {
// Deduplicate concurrent requests for the same pubkey's relay list // Deduplicate concurrent requests for the same pubkey's relay list
const existingRequest = this.relayListRequestCache.get(pubkey) const existingRequest = this.relayListRequestCache.get(pubkey)
if (existingRequest) { if (existingRequest) {
logger.debug('[FetchRelayList] Using cached in-flight request', { pubkey: pubkey.substring(0, 8) })
return existingRequest return existingRequest
} }
logger.debug('[FetchRelayList] Starting fetch', { pubkey: pubkey.substring(0, 8) })
const requestPromise = (async () => { const requestPromise = (async () => {
try { try {
const startTime = Date.now()
const [relayList] = await this.fetchRelayLists([pubkey]) const [relayList] = await this.fetchRelayLists([pubkey])
const duration = Date.now() - startTime
logger.debug('[FetchRelayList] Fetch completed', {
pubkey: pubkey.substring(0, 8),
duration: `${duration}ms`,
hasRelayList: !!relayList,
writeCount: relayList?.write?.length ?? 0,
readCount: relayList?.read?.length ?? 0
})
return relayList return relayList
} catch (error) {
logger.error('[FetchRelayList] Fetch failed', {
pubkey: pubkey.substring(0, 8),
error: error instanceof Error ? error.message : String(error)
})
throw error
} finally { } finally {
// Remove from cache after completion (cache result in replaceableEventCacheMap) // Remove from cache after completion (cache result in replaceableEventCacheMap)
this.relayListRequestCache.delete(pubkey) this.relayListRequestCache.delete(pubkey)

116
src/services/relay-selection.service.ts

@ -304,12 +304,9 @@ class RelaySelectionService {
// Deduplicate the selected relays // Deduplicate the selected relays
selectedRelays = Array.from(new Set(selectedRelays)) selectedRelays = Array.from(new Set(selectedRelays))
} }
// For discussion replies, use relay hint from the kind 11 at the top of the thread // For discussion replies, use relay hints from the kind 11 + user's outboxes + local relays + thecitadel
else if (parentEvent && (parentEvent.kind === ExtendedKind.DISCUSSION || parentEvent.kind === ExtendedKind.COMMENT)) { else if (parentEvent && (parentEvent.kind === ExtendedKind.DISCUSSION || parentEvent.kind === ExtendedKind.COMMENT)) {
const discussionRelay = this.getDiscussionRelayHint(parentEvent) selectedRelays = await this.getDiscussionReplyRelays(context)
if (discussionRelay) {
selectedRelays = [discussionRelay]
}
} }
// For public messages, use sender outboxes + receiver inboxes // For public messages, use sender outboxes + receiver inboxes
else if (isPublicMessage || (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE)) { else if (isPublicMessage || (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE)) {
@ -567,30 +564,109 @@ class RelaySelectionService {
} }
/** /**
* Get relay hint from discussion events * Get all relay hints from a kind 11 discussion event
* Returns all relays where the event was seen (excluding local relays)
*/
private getDiscussionRelayHints(discussionEventId: string): string[] {
const eventHints = client.getEventHints(discussionEventId)
return eventHints.map(url => normalizeUrl(url) || url).filter(Boolean)
}
/**
* Get relays for discussion replies (kind 11 or kind 1111)
* Includes: relay hints from kind 11, wss://thecitadel.nostr1.com, user's outboxes, and local relays
*/ */
private getDiscussionRelayHint(parentEvent: Event): string | null { private async getDiscussionReplyRelays(context: RelaySelectionContext): Promise<string[]> {
// For kind 1111 (COMMENT): look for 'E' tag which points to the root event const { parentEvent, userWriteRelays, userPubkey, blockedRelays } = context
if (!parentEvent) return []
const relayUrls = new Set<string>()
// Step 1: Get relay hints from the kind 11 event
let discussionEventId: string | null = null
if (parentEvent.kind === ExtendedKind.COMMENT) { if (parentEvent.kind === ExtendedKind.COMMENT) {
// For kind 1111 (COMMENT): get root kind 11 event ID from E tag
const ETag = parentEvent.tags.find(tag => tag[0] === 'E') const ETag = parentEvent.tags.find(tag => tag[0] === 'E')
if (ETag && ETag[2]) { if (ETag && ETag[1]) {
return normalizeUrl(ETag[2]) || ETag[2] discussionEventId = ETag[1]
} else {
// Fallback to lowercase e tag
const eTag = parentEvent.tags.find(tag => tag[0] === 'e')
if (eTag && eTag[1]) {
discussionEventId = eTag[1]
}
} }
} else if (parentEvent.kind === ExtendedKind.DISCUSSION) {
// For kind 11 (DISCUSSION): use the event itself
discussionEventId = parentEvent.id
}
// Get all relay hints from the kind 11 event
if (discussionEventId) {
const discussionHints = this.getDiscussionRelayHints(discussionEventId)
discussionHints.forEach(url => relayUrls.add(url))
}
// Step 2: Add wss://thecitadel.nostr1.com
const thecitadelUrl = normalizeUrl('wss://thecitadel.nostr1.com')
if (thecitadelUrl) {
relayUrls.add(thecitadelUrl)
}
// If no 'E' tag, check lowercase 'e' tag for parent event // Step 3: Add user's outboxes (write relays from kind 10002)
const eTag = parentEvent.tags.find(tag => tag[0] === 'e') if (userWriteRelays.length > 0) {
if (eTag && eTag[2]) { userWriteRelays.forEach(url => {
return normalizeUrl(eTag[2]) || eTag[2] const normalized = normalizeUrl(url)
if (normalized) {
relayUrls.add(normalized)
}
})
} else if (userPubkey) {
// Fetch user's relay list if not provided
try {
const relayList = await this.getCachedRelayList(userPubkey)
if (relayList?.write) {
relayList.write.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) {
relayUrls.add(normalized)
}
})
}
} catch (error) {
logger.warn('Failed to fetch user relay list for discussion reply', { error, userPubkey })
} }
} else if (parentEvent.kind === ExtendedKind.DISCUSSION) { }
// For kind 11 (DISCUSSION): get relay hint from where it was found
const eventHints = client.getEventHints(parentEvent.id) // Step 4: Add local relays (cache relays from kind 10432)
if (eventHints.length > 0) { if (userPubkey) {
return normalizeUrl(eventHints[0]) || eventHints[0] try {
const cacheRelayEvent = await indexedDb.getReplaceableEvent(userPubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayEvent) {
cacheRelayEvent.tags.forEach(tag => {
if (tag[0] === 'relay' && tag[1]) {
const normalized = normalizeUrl(tag[1])
if (normalized) {
relayUrls.add(normalized)
}
}
})
}
} catch (error) {
logger.warn('Failed to fetch cache relays for discussion reply', { error, userPubkey })
} }
} }
return null // Step 5: Convert to array, normalize, and deduplicate
const normalizedRelays = Array.from(relayUrls)
.map(url => normalizeUrl(url))
.filter((url): url is string => !!url)
const deduplicatedRelays = Array.from(new Set(normalizedRelays))
// Step 6: Filter out blocked relays
return this.filterBlockedRelays(deduplicatedRelays, blockedRelays)
} }
/** /**

Loading…
Cancel
Save