From 5e47a344827988610bcb5cae096c74c3b75942a6 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 9 Feb 2026 21:48:36 +0100 Subject: [PATCH] bug-fixes --- package-lock.json | 4 +- src/PageManager.tsx | 2 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 65 +++++++- src/components/Note/Poll.tsx | 18 +- src/components/NoteOptions/ReportDialog.tsx | 20 ++- src/components/NoteOptions/useMenuActions.tsx | 27 ++- src/components/NoteStats/LikeButton.tsx | 34 +++- src/components/NoteStats/RepostButton.tsx | 17 ++ src/components/NoteStats/VoteButtons.tsx | 35 ++++ src/components/RelayStatusDisplay/index.tsx | 51 +++++- src/index.css | 19 +++ src/lib/publishing-feedback.tsx | 1 + .../DiscussionsPage/CreateThreadDialog.tsx | 17 +- src/pages/primary/NoteListPage/RelaysFeed.tsx | 6 + src/providers/FeedProvider.tsx | 154 +++++++++--------- src/services/client.service.ts | 64 +++++++- src/services/relay-selection.service.ts | 118 +++++++++++--- 17 files changed, 535 insertions(+), 117 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc89e64..185eb2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "15.0.0", + "version": "16.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "15.0.0", + "version": "16.1.1", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index c69a60e..1b5d83c 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -101,7 +101,7 @@ const getPrimaryPageMap = () => ({ // Type for primary page names - use the return type of getPrimaryPageMap export type TPrimaryPageName = keyof ReturnType -const PrimaryPageContext = createContext(undefined) +export const PrimaryPageContext = createContext(undefined) const SecondaryPageContext = createContext(undefined) diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index f3508ce..23f876b 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/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*/)) { - // 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 blockquoteStartIndex = lineStartIndex let blockquoteLineIdx = lineIdx let tempIndex = lineStartIndex + let allGreentext = isGreentext while (blockquoteLineIdx < lines.length) { const blockquoteLine = lines[blockquoteLineIdx] + const lineGreentextMatch = blockquoteLine.match(/^>([^\s>].*)$/) + const lineIsGreentext = lineGreentextMatch !== null + 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 const content = blockquoteLine.replace(/^>\s?/, '') blockquoteLines.push(content) blockquoteLineIdx++ 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() === '') { - // 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 break } 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 } } @@ -693,10 +714,13 @@ function parseMarkdownContent( // Calculate end index: tempIndex - 1 (subtract 1 because we don't want the trailing newline) const blockquoteEndIndex = tempIndex - 1 + // Use greentext type if all lines are greentext, otherwise use blockquote + const patternType = allGreentext ? 'greentext' : 'blockquote' + blockPatterns.push({ index: blockquoteStartIndex, end: blockquoteEndIndex, - type: 'blockquote', + type: patternType, data: { lines: blockquoteLines, lineNum: lineIdx } }) // 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) // 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 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 otherPatterns = patterns.filter(p => !blockLevelTypes.includes(p.type)) @@ -1221,7 +1245,9 @@ function parseMarkdownContent( pattern.type !== 'numbered-list-item' && pattern.type !== 'table' && 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 // Skip it to avoid duplicate rendering return @@ -1973,6 +1999,29 @@ function parseMarkdownContent( {blockquoteContent} ) + } else if (pattern.type === 'greentext') { + const { lines } = pattern.data + // Join all greentext lines with
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 ( + + {lineIdx > 0 &&
} + >{lineContent} +
+ ) + }) + + parts.push( + + {greentextContent} + + ) } else if (pattern.type === 'fenced-code-block') { const { code, language } = pattern.data // Render code block with syntax highlighting diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index b0507d7..1feb994 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import logger from '@/lib/logger' +import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' export default function Poll({ event, className }: { event: Event; className?: string }) { 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 draftEvent = createPollResponseDraftEvent(event, selectedOptionIds) - await publish(draftEvent, { + const publishedEvent = await publish(draftEvent, { 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([]) pollResultsService.addPollResponse(event.id, pubkey, selectedOptionIds) } catch (error) { diff --git a/src/components/NoteOptions/ReportDialog.tsx b/src/components/NoteOptions/ReportDialog.tsx index f1c329d..0194f6f 100644 --- a/src/components/NoteOptions/ReportDialog.tsx +++ b/src/components/NoteOptions/ReportDialog.tsx @@ -90,8 +90,24 @@ function ReportContent({ event, closeDialog }: { event: NostrEvent; closeDialog: try { setReporting(true) const draftEvent = createReportDraftEvent(event, reason) - await publish(draftEvent) - toast.success(t('Successfully report')) + const publishedEvent = await publish(draftEvent) + + // 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() } catch (error) { toast.error(t('Failed to report') + ': ' + (error as Error).message) diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 06c8c70..96ab815 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/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 { Event, kinds } 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 { toast } from 'sonner' import RelayIcon from '../RelayIcon' -import { usePrimaryPage } from '@/PageManager' +import { PrimaryPageContext } from '@/PageManager' +import { showPublishingFeedback } from '@/lib/publishing-feedback' export interface SubMenuAction { label: React.ReactNode @@ -57,7 +58,9 @@ export function useMenuActions({ openHighlightEditor }: UseMenuActionsProps) { 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 { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relaySets, favoriteRelays } = useFavoriteRelays() @@ -182,7 +185,7 @@ export function useMenuActions({ // Create and publish the new pin list event logger.component('PinNote', 'Publishing new pin list event', { tagCount: newTags.length, relayCount: comprehensiveRelays.length }) - await publish({ + const publishedEvent = await publish({ kind: 10001, tags: newTags, content: '', @@ -191,9 +194,23 @@ export function useMenuActions({ 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 setIsPinned(!isPinned) - toast.success(successMessage) closeDrawer() } catch (error) { logger.component('PinNote', 'Error pinning/unpinning note', { error: (error as Error).message }) diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 656edb4..f2ad848 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -24,6 +24,7 @@ import Emoji from '../Emoji' import EmojiPicker from '../EmojiPicker' import SuggestedEmojis from '../SuggestedEmojis' import { formatCount } from './utils' +import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' const DISCUSSION_EMOJIS = ['⬆️', '⬇️'] @@ -120,13 +121,44 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; if (reactionEvent) { // Create and publish a deletion request (kind 5) 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 { // User is adding a new reaction const reaction = createReactionDraftEvent(event, emoji) 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]) } } catch (error) { diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index 2d3d8ed..62ce871 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next' import logger from '@/lib/logger' import PostEditor from '../PostEditor' import { formatCount } from './utils' +import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' export default function RepostButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { const { t } = useTranslation() @@ -59,6 +60,22 @@ export default function RepostButton({ event, hideCount = false }: { event: Even const repost = createRepostDraftEvent(event) 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]) } catch (error) { logger.error('Repost failed', { error, eventId: event.id }) diff --git a/src/components/NoteStats/VoteButtons.tsx b/src/components/NoteStats/VoteButtons.tsx index dad8eea..b401db6 100644 --- a/src/components/NoteStats/VoteButtons.tsx +++ b/src/components/NoteStats/VoteButtons.tsx @@ -7,8 +7,11 @@ import { ChevronDown, ChevronUp } from 'lucide-react' import { useMemo, useState } from 'react' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import logger from '@/lib/logger' +import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' +import { useTranslation } from 'react-i18next' export default function VoteButtons({ event }: { event: Event }) { + const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() const [voting, setVoting] = useState(null) 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) const reaction = createReactionDraftEvent(event, emoji) 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]) } else { // 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 const reaction = createReactionDraftEvent(event, emoji) 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]) } } catch (error) { diff --git a/src/components/RelayStatusDisplay/index.tsx b/src/components/RelayStatusDisplay/index.tsx index 1349b82..74b6029 100644 --- a/src/components/RelayStatusDisplay/index.tsx +++ b/src/components/RelayStatusDisplay/index.tsx @@ -1,3 +1,4 @@ +import React from 'react' import { Check, X } from 'lucide-react' import { simplifyUrl } from '@/lib/url' @@ -44,10 +45,53 @@ function formatRelayError(error: string): string { 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( + e.stopPropagation()} + > + {url} + + ) + + 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 { url: string success: boolean error?: string + message?: string authAttempted?: boolean } @@ -103,7 +147,12 @@ export default function RelayStatusDisplay({ {!status.success && status.error && (
- {formatRelayError(status.error)} + {renderTextWithLinks(formatRelayError(status.error))} +
+ )} + {status.success && status.message && ( +
+ {renderTextWithLinks(status.message)}
)} diff --git a/src/index.css b/src/index.css index 1df106f..3ff419b 100644 --- a/src/index.css +++ b/src/index.css @@ -425,3 +425,22 @@ 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; +} diff --git a/src/lib/publishing-feedback.tsx b/src/lib/publishing-feedback.tsx index b93347b..092a496 100644 --- a/src/lib/publishing-feedback.tsx +++ b/src/lib/publishing-feedback.tsx @@ -6,6 +6,7 @@ export type RelayStatus = { url: string success: boolean error?: string + message?: string authAttempted?: boolean } diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 5cc6163..9a6e45a 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -19,7 +19,7 @@ import { useGroupList } from '@/providers/GroupListProvider' import { TDraftEvent, TRelaySet } from '@/types' import { NostrEvent } from 'nostr-tools' 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 relaySelectionService from '@/services/relay-selection.service' import dayjs from 'dayjs' @@ -460,6 +460,21 @@ export default function CreateThreadDialog({ 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) onClose() } else { diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 11bf2be..7a3493f 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -20,6 +20,12 @@ export default function RelaysFeed() { useEffect(() => { 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) setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))) setIsReady(true) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 017b43e..7cf48b0 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -6,7 +6,7 @@ import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import { TFeedInfo, TFeedType } from '@/types' 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 { useNostr } from './NostrProvider' @@ -41,77 +41,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { }) const feedInfoRef = useRef(feedInfo) - useEffect(() => { - 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 ( + const switchFeed = useCallback(async ( feedType: TFeedType, options: { activeRelaySetId?: string | null @@ -232,7 +162,85 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { return } 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 ( { try { + const startTime = Date.now() 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 + } catch (error) { + logger.error('[FetchRelayList] Fetch failed', { + pubkey: pubkey.substring(0, 8), + error: error instanceof Error ? error.message : String(error) + }) + throw error } finally { // Remove from cache after completion (cache result in replaceableEventCacheMap) this.relayListRequestCache.delete(pubkey) diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index 380c6fe..3e60bda 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -304,12 +304,9 @@ class RelaySelectionService { // Deduplicate the selected relays 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)) { - const discussionRelay = this.getDiscussionRelayHint(parentEvent) - if (discussionRelay) { - selectedRelays = [discussionRelay] - } + selectedRelays = await this.getDiscussionReplyRelays(context) } // For public messages, use sender outboxes + receiver inboxes 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 { - // For kind 1111 (COMMENT): look for 'E' tag which points to the root event + private async getDiscussionReplyRelays(context: RelaySelectionContext): Promise { + const { parentEvent, userWriteRelays, userPubkey, blockedRelays } = context + if (!parentEvent) return [] + + const relayUrls = new Set() + + // Step 1: Get relay hints from the kind 11 event + let discussionEventId: string | null = null + 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') - if (ETag && ETag[2]) { - return normalizeUrl(ETag[2]) || ETag[2] - } - - // If no 'E' tag, check lowercase 'e' tag for parent event - const eTag = parentEvent.tags.find(tag => tag[0] === 'e') - if (eTag && eTag[2]) { - return normalizeUrl(eTag[2]) || eTag[2] + if (ETag && ETag[1]) { + 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): get relay hint from where it was found - const eventHints = client.getEventHints(parentEvent.id) - if (eventHints.length > 0) { - return normalizeUrl(eventHints[0]) || eventHints[0] + // 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) + } + + // Step 3: Add user's outboxes (write relays from kind 10002) + if (userWriteRelays.length > 0) { + userWriteRelays.forEach(url => { + 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 }) } } - return null + // Step 4: Add local relays (cache relays from kind 10432) + if (userPubkey) { + 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 }) + } + } + + // 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) } /**