From 96a2ece8e11c09b1824e9be8edf7e69576ab5bb3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 21 Mar 2026 01:51:55 +0100 Subject: [PATCH] refactor to a spells-based client --- src/PageManager.tsx | 146 +-- .../BottomNavigationBar/DiscussionsButton.tsx | 4 +- .../NotificationsButton.tsx | 6 +- src/components/BottomNavigationBar/index.tsx | 2 +- src/components/FeedSwitcher/index.tsx | 33 +- src/components/Note/EventViewer.tsx | 174 ++++ src/components/Note/UnknownNote.tsx | 28 +- src/components/NoteOptions/useMenuActions.tsx | 7 +- src/components/NotificationList/index.tsx | 8 +- src/components/PostEditor/PostContent.tsx | 42 +- src/components/Profile/ProfileArticles.tsx | 58 -- .../Profile/ProfileBookmarksAndHashtags.tsx | 964 ------------------ .../Profile/ProfileFeedWithPins.tsx | 195 ++++ .../Profile/ProfileInteractions.tsx | 315 ------ src/components/Profile/ProfileMedia.tsx | 57 -- src/components/Profile/ProfileNotes.tsx | 188 ---- src/components/Profile/index.tsx | 391 +------ src/components/ProfileOptions/index.tsx | 2 +- src/components/Sidebar/DiscussionsButton.tsx | 4 +- src/components/Sidebar/NotificationButton.tsx | 13 +- src/components/Sidebar/ProfileButton.tsx | 27 - src/components/Sidebar/SpellsButton.tsx | 11 +- src/components/Sidebar/index.tsx | 10 +- src/constants.ts | 22 + src/hooks/useProfileNotesTimeline.tsx | 194 ---- src/hooks/useProfilePins.tsx | 181 ++++ src/pages/primary/DiscussionsPage/index.tsx | 45 +- src/pages/primary/NoteListPage/FeedButton.tsx | 107 -- src/pages/primary/NoteListPage/index.tsx | 2 - .../primary/NotificationListPage/index.tsx | 87 -- src/pages/primary/SpellsPage/index.tsx | 149 ++- src/providers/KindFilterProvider.tsx | 12 +- src/routes.tsx | 2 +- 33 files changed, 950 insertions(+), 2536 deletions(-) create mode 100644 src/components/Note/EventViewer.tsx delete mode 100644 src/components/Profile/ProfileArticles.tsx delete mode 100644 src/components/Profile/ProfileBookmarksAndHashtags.tsx create mode 100644 src/components/Profile/ProfileFeedWithPins.tsx delete mode 100644 src/components/Profile/ProfileInteractions.tsx delete mode 100644 src/components/Profile/ProfileMedia.tsx delete mode 100644 src/components/Profile/ProfileNotes.tsx delete mode 100644 src/components/Sidebar/ProfileButton.tsx delete mode 100644 src/hooks/useProfileNotesTimeline.tsx create mode 100644 src/hooks/useProfilePins.tsx delete mode 100644 src/pages/primary/NoteListPage/FeedButton.tsx delete mode 100644 src/pages/primary/NotificationListPage/index.tsx diff --git a/src/PageManager.tsx b/src/PageManager.tsx index f03ba5f3..330b793d 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -50,11 +50,9 @@ import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog' import { normalizeUrl } from './lib/url' import ExplorePage from './pages/primary/ExplorePage' import MePage from './pages/primary/MePage' -import NotificationListPage from './pages/primary/NotificationListPage' import ProfilePage from './pages/primary/ProfilePage' import RelayPage from './pages/primary/RelayPage' import SearchPage from './pages/primary/SearchPage' -import DiscussionsPage from './pages/primary/DiscussionsPage' import { useScreenSize } from './providers/ScreenSizeProvider' /** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */ @@ -88,12 +86,10 @@ type TStackItem = { const PRIMARY_PAGE_REF_MAP = { home: createRef(), explore: createRef(), - notifications: createRef(), me: createRef(), profile: createRef(), relay: createRef(), search: createRef(), - discussions: createRef(), spells: createRef() } @@ -102,12 +98,10 @@ const PRIMARY_PAGE_REF_MAP = { const getPrimaryPageMap = () => ({ home: , explore: , - notifications: , me: , profile: , relay: , search: , - discussions: , spells: ( ({ // Type for primary page names - use the return type of getPrimaryPageMap export type TPrimaryPageName = keyof ReturnType +type TPrimaryPageStateEntry = { name: TPrimaryPageName; element: ReactNode; props?: any } + +/** /discussions and contextual /discussions/notes/* map to spells + faux discussions. */ +function noteContextToPrimaryEntry(pageContext: string): { name: TPrimaryPageName; props?: object } | null { + if (pageContext === 'discussions') { + return { name: 'spells', props: { spell: 'discussions' } } + } + const map = getPrimaryPageMap() + if (pageContext in map) { + return { name: pageContext as TPrimaryPageName } + } + return null +} + +function mergePrimaryPageEntry( + prev: TPrimaryPageStateEntry[], + entry: { name: TPrimaryPageName; props?: object } +): TPrimaryPageStateEntry[] { + const map = getPrimaryPageMap() + const element = map[entry.name] + const exists = prev.find((p) => p.name === entry.name) + if (exists) { + if (entry.props) { + exists.props = { ...(exists.props || {}), ...entry.props } + } + return [...prev] + } + return [...prev, { name: entry.name, element, props: entry.props }] +} + export const PrimaryPageContext = createContext(undefined) const SecondaryPageContext = createContext(undefined) @@ -178,7 +202,7 @@ export function useNoteDrawer() { // Helper function to build contextual note URL function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): string { // Pages that should preserve context in the URL - const contextualPages: TPrimaryPageName[] = ['discussions', 'search', 'profile', 'explore', 'notifications'] + const contextualPages: TPrimaryPageName[] = ['search', 'profile', 'explore', 'spells'] if (currentPage && contextualPages.includes(currentPage) && currentPage !== 'home') { return `/${currentPage}/notes/${noteId}` @@ -202,7 +226,7 @@ function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): // Helper function to extract noteId and context from URL function parseNoteUrl(url: string): { noteId: string; context?: string } { // Match patterns like /discussions/notes/{noteId} or /notes/{noteId} - const contextualMatch = url.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/) + const contextualMatch = url.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) if (contextualMatch) { return { noteId: contextualMatch[2], context: contextualMatch[1] } } @@ -278,7 +302,7 @@ export function useSmartRelayNavigation() { const navigateToRelay = (url: string) => { // Extract relay URL from path (handles both /relays/{url} and /{context}/relays/{url}) - const relayUrlMatch = url.match(/\/(discussions|search|profile|explore|notifications)\/relays\/(.+)$/) || + const relayUrlMatch = url.match(/\/(discussions|search|profile|explore|spells)\/relays\/(.+)$/) || url.match(/\/relays\/(.+)$/) const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, '')) @@ -624,10 +648,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Get current tab state from ref (updated by components via events) const currentTab = currentTabStateRef.current.get(currentPrimaryPage) - // Get Discussions state if on discussions page + // Discussions list state when Spells page may host embedded Discussions let discussionsState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | undefined = undefined - if (currentPrimaryPage === 'discussions') { - // Request discussions state from component + if (currentPrimaryPage === 'spells') { + // Request discussions state from embedded Discussions (faux-spell) when mounted const stateEvent = new CustomEvent('requestDiscussionsState') let receivedState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | null = null const handler = ((e: CustomEvent) => { @@ -690,7 +714,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Restore Discussions state - if (savedFeedState?.discussionsState && savedPrimaryPage === 'discussions') { + if (savedFeedState?.discussionsState && savedPrimaryPage === 'spells') { logger.info('PageManager: Restoring Discussions state', { page: savedPrimaryPage, discussionsState: savedFeedState.discussionsState @@ -788,7 +812,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const pathname = window.location.pathname // Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id} - const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/) + const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) const standardNoteMatch = pathname.match(/\/notes\/(.+)$/) const noteUrlMatch = contextualNoteMatch || standardNoteMatch @@ -797,38 +821,27 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (noteId) { // If this is a contextual note URL, set the primary page first if (contextualNoteMatch) { - const pageContext = contextualNoteMatch[1] as TPrimaryPageName - if (pageContext in getPrimaryPageMap()) { + const pageContext = contextualNoteMatch[1] + const resolved = noteContextToPrimaryEntry(pageContext) + if (resolved) { // Open drawer immediately, then load background page asynchronously // This prevents the background page loading from blocking the drawer if (isSmallScreen || panelMode === 'single') { // Single-pane mode or mobile: open drawer first openDrawer(noteId) - + // Load background page asynchronously after drawer opens setTimeout(() => { - setCurrentPrimaryPage(pageContext) - setPrimaryPages((prev) => { - const exists = prev.find((p) => p.name === pageContext) - if (!exists) { - return [...prev, { name: pageContext, element: getPrimaryPageMap()[pageContext] }] - } - return prev - }) - setSavedPrimaryPage(pageContext) + setCurrentPrimaryPage(resolved.name) + setPrimaryPages((prev) => mergePrimaryPageEntry(prev, resolved)) + setSavedPrimaryPage(resolved.name) }, 0) return } else { // Double-pane mode: set page immediately (no drawer) - setCurrentPrimaryPage(pageContext) - setPrimaryPages((prev) => { - const exists = prev.find((p) => p.name === pageContext) - if (!exists) { - return [...prev, { name: pageContext, element: getPrimaryPageMap()[pageContext] }] - } - return prev - }) - setSavedPrimaryPage(pageContext) + setCurrentPrimaryPage(resolved.name) + setPrimaryPages((prev) => mergePrimaryPageEntry(prev, resolved)) + setSavedPrimaryPage(resolved.name) } } } @@ -868,15 +881,23 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Check if this is a primary page URL - don't push primary pages to secondary stack const pathnameOnly = pathname.split('?')[0].split('#')[0] - const isPrimaryPageUrl = pathnameOnly === '/' || pathnameOnly === '/home' || - (pathnameOnly.startsWith('/') && pathnameOnly.slice(1).split('/')[0] in getPrimaryPageMap() && - !pathnameOnly.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/)) - + const firstSeg = pathnameOnly.slice(1).split('/')[0] + const isPrimaryPageUrl = + pathnameOnly === '/' || + pathnameOnly === '/home' || + firstSeg === 'discussions' || + (pathnameOnly.startsWith('/') && + firstSeg in getPrimaryPageMap() && + !pathnameOnly.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/)) + if (isPrimaryPageUrl) { // This is a primary page - just navigate to it, don't push to secondary stack - const pageName = pathnameOnly === '/' || pathnameOnly === '/home' ? 'home' : pathnameOnly.slice(1).split('/')[0] as TPrimaryPageName - if (pageName in getPrimaryPageMap()) { - navigatePrimaryPage(pageName) + const pageName = + pathnameOnly === '/' || pathnameOnly === '/home' ? 'home' : firstSeg + if (pageName === 'discussions') { + navigatePrimaryPage('spells', { spell: 'discussions' }) + } else if (pageName in getPrimaryPageMap()) { + navigatePrimaryPage(pageName as TPrimaryPageName) } return } @@ -919,19 +940,23 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } else { // Check if pathname matches a primary page name // First, check if it's a contextual note URL (e.g., /discussions/notes/...) - const contextualNoteMatch = pathname.match(/^\/(discussions|search|profile|explore|notifications)\/notes\//) + const contextualNoteMatch = pathname.match(/^\/(discussions|search|profile|explore|spells)\/notes\//) if (contextualNoteMatch) { - // Extract the page context from the URL - const pageContext = contextualNoteMatch[1] as TPrimaryPageName - if (pageContext in getPrimaryPageMap()) { - navigatePrimaryPage(pageContext) + const pageContext = contextualNoteMatch[1] + const resolved = noteContextToPrimaryEntry(pageContext) + if (resolved) { + navigatePrimaryPage(resolved.name, resolved.props) // The note URL will be handled by the note URL parsing above } return } - + // Check if it's a standard primary page path const pageName: string = pathname.slice(1).split('/')[0] // Get first segment after slash + if (pageName === 'discussions') { + navigatePrimaryPage('spells', { spell: 'discussions' }) + return + } if (pageName && pageName in getPrimaryPageMap()) { // For relay page, check if there's a URL prop if (pageName === 'relay') { @@ -965,7 +990,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const urlToCheck = state?.url || window.location.pathname // Check if it's a note URL (we'll update drawer after stack is synced) - const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/) || + const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) || urlToCheck.match(/\/notes\/(.+)$/) const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null @@ -1011,9 +1036,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (!topItem) { // Stack is empty - check if this is a primary page URL or a secondary route const pathname = state.url.split('?')[0].split('#')[0] - const isPrimaryPage = pathname === '/' || pathname === '/home' || - (pathname.startsWith('/') && pathname.slice(1).split('/')[0] in getPrimaryPageMap() && - !pathname.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/)) + const popFirstSeg = pathname.slice(1).split('/')[0] + const isPrimaryPage = + pathname === '/' || + pathname === '/home' || + popFirstSeg === 'discussions' || + (pathname.startsWith('/') && + popFirstSeg in getPrimaryPageMap() && + !pathname.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/)) // If it's a primary page URL, return empty stack (right panel will close) if (isPrimaryPage) { @@ -1031,7 +1061,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id}) - const noteUrlMatch = state.url.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/) || + const noteUrlMatch = state.url.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) || state.url.match(/\/notes\/(.+)$/) if (noteUrlMatch) { const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] @@ -1082,7 +1112,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Extract noteId from top item's URL or from state.url const topItemUrl = newStack[newStack.length - 1]?.url || state?.url if (topItemUrl) { - const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|explore|notifications)\/notes\/(.+)$/) || + const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) || topItemUrl.match(/\/notes\/(.+)$/) if (topNoteUrlMatch) { const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0] @@ -1153,7 +1183,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Restore Discussions state - if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') { + if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') { logger.info('PageManager: Browser back - Restoring Discussions state', { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState @@ -1287,7 +1317,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Restore Discussions state - if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') { + if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') { logger.info('PageManager: Desktop - Restoring Discussions state', { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState @@ -1356,7 +1386,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Restore Discussions state - if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') { + if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') { logger.info('PageManager: Mobile/Single-pane - Restoring Discussions state', { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState @@ -1398,7 +1428,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Restore Discussions state - if (savedFeedState?.discussionsState && currentPrimaryPage === 'discussions') { + if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') { logger.info('PageManager: Desktop - Restoring Discussions state', { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState diff --git a/src/components/BottomNavigationBar/DiscussionsButton.tsx b/src/components/BottomNavigationBar/DiscussionsButton.tsx index 5bb3ae9e..27302468 100644 --- a/src/components/BottomNavigationBar/DiscussionsButton.tsx +++ b/src/components/BottomNavigationBar/DiscussionsButton.tsx @@ -7,8 +7,8 @@ export default function DiscussionsButton() { return ( navigate('discussions')} + active={current === 'spells' && display} + onClick={() => navigate('spells', { spell: 'discussions' })} > diff --git a/src/components/BottomNavigationBar/NotificationsButton.tsx b/src/components/BottomNavigationBar/NotificationsButton.tsx index 21ca8089..73950328 100644 --- a/src/components/BottomNavigationBar/NotificationsButton.tsx +++ b/src/components/BottomNavigationBar/NotificationsButton.tsx @@ -4,13 +4,13 @@ import { Bell } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' export default function NotificationsButton() { + const { navigate } = usePrimaryPage() const { checkLogin } = useNostr() - const { navigate, current, display } = usePrimaryPage() return ( checkLogin(() => navigate('notifications'))} + active={false} + onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))} > diff --git a/src/components/BottomNavigationBar/index.tsx b/src/components/BottomNavigationBar/index.tsx index 0431700c..0965aa81 100644 --- a/src/components/BottomNavigationBar/index.tsx +++ b/src/components/BottomNavigationBar/index.tsx @@ -1,7 +1,7 @@ import { cn } from '@/lib/utils' import HomeButton from './HomeButton' -import NotificationsButton from './NotificationsButton' import DiscussionsButton from './DiscussionsButton' +import NotificationsButton from './NotificationsButton' import SearchButton from './SearchButton' import SpellsButton from './SpellsButton' import WriteButton from './WriteButton' diff --git a/src/components/FeedSwitcher/index.tsx b/src/components/FeedSwitcher/index.tsx index a5b5d3f9..6034741a 100644 --- a/src/components/FeedSwitcher/index.tsx +++ b/src/components/FeedSwitcher/index.tsx @@ -19,58 +19,59 @@ export default function FeedSwitcher({ close }: { close?: () => void }) { // Filter out blocked relays for display const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay)) + // Feed rows that exist here follow FAUX_SPELL_ORDER where applicable: favorite-relays → following → bookmarks. return (
- {pubkey && ( + {visibleRelays.length > 0 && ( { - if (!pubkey) return - switchFeed('following', { pubkey }) + logger.debug('FeedSwitcher: Switching to all-favorites') + switchFeed('all-favorites') close?.() }} >
- +
-
{t('Following')}
+
{t('All favorite relays')}
)} {pubkey && ( { if (!pubkey) return - switchFeed('bookmarks', { pubkey }) + switchFeed('following', { pubkey }) close?.() }} >
- +
-
{t('Bookmarks')}
+
{t('Following')}
)} - {visibleRelays.length > 0 && ( + {pubkey && ( { - logger.debug('FeedSwitcher: Switching to all-favorites') - switchFeed('all-favorites') + if (!pubkey) return + switchFeed('bookmarks', { pubkey }) close?.() }} >
- +
-
{t('All favorite relays')}
+
{t('Bookmarks')}
)} diff --git a/src/components/Note/EventViewer.tsx b/src/components/Note/EventViewer.tsx new file mode 100644 index 00000000..060f6681 --- /dev/null +++ b/src/components/Note/EventViewer.tsx @@ -0,0 +1,174 @@ +import { Event, nip19 } from 'nostr-tools' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import dayjs from 'dayjs' +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react' +import { toast } from 'sonner' +import logger from '@/lib/logger' +import { cn } from '@/lib/utils' +import UserAvatar from '@/components/UserAvatar' +import Username from '@/components/Username' + +export default function EventViewer({ event, className }: { event: Event; className?: string }) { + const { t } = useTranslation() + const [copiedJson, setCopiedJson] = useState(false) + const [copiedNevent, setCopiedNevent] = useState(false) + const [expanded, setExpanded] = useState>(new Set(['root'])) + + const nevent = useMemo( + () => nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind }), + [event.id, event.pubkey, event.kind] + ) + + const toggle = (key: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + const handleCopyJson = async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(event, null, 2)) + setCopiedJson(true) + toast.success(t('Copied to clipboard')) + setTimeout(() => setCopiedJson(false), 2000) + } catch (err) { + logger.error('Failed to copy event JSON', { error: err, eventId: event.id }) + toast.error(t('Failed to copy')) + } + } + + const handleCopyNevent = async () => { + try { + await navigator.clipboard.writeText(nevent) + setCopiedNevent(true) + toast.success(t('Copied to clipboard')) + setTimeout(() => setCopiedNevent(false), 2000) + } catch (err) { + logger.error('Failed to copy nevent', { error: err }) + toast.error(t('Failed to copy')) + } + } + + const renderValue = (value: unknown, key: string, depth = 0): React.ReactNode => { + if (value === null) { + return null + } + if (value === undefined) { + return undefined + } + if (typeof value === 'string') { + return "{value}" + } + if (typeof value === 'number' || typeof value === 'boolean') { + return {String(value)} + } + if (Array.isArray(value)) { + const isExpanded = expanded.has(key) + return ( +
0 && 'border-l border-border/50 pl-2')}> + toggle(key)}> + + {isExpanded ? ( + + ) : ( + + )} + Array + ({value.length}) + + + {value.map((item, idx) => ( +
+ [{idx}]{' '} + {renderValue(item, `${key}[${idx}]`, depth + 1)} +
+ ))} +
+
+
+ ) + } + if (typeof value === 'object') { + const isExpanded = expanded.has(key) + const entries = Object.entries(value) + return ( +
0 && 'border-l border-border/50 pl-2')}> + toggle(key)}> + + {isExpanded ? ( + + ) : ( + + )} + Object + ({entries.length} keys) + + + {entries.map(([k, v]) => ( +
+ "{k}":{' '} + {renderValue(v, `${key}.${k}`, depth + 1)} +
+ ))} +
+
+
+ ) + } + return {String(value)} + } + + const createdAtFormatted = dayjs(event.created_at * 1000).format('LLL') + + return ( +
+
+
Event (kind {event.kind})
+ +
+
+
+ nevent + {nevent} + +
+
+ pubkey +
+ + +
+
+
+ kind{' '} + {renderValue(event.kind, 'kind')} +
+
+ created_at{' '} + {createdAtFormatted} +
+
+ tags{' '} + {renderValue(event.tags, 'tags')} +
+
+ content{' '} + {renderValue(event.content, 'content')} +
+
+
+ ) +} diff --git a/src/components/Note/UnknownNote.tsx b/src/components/Note/UnknownNote.tsx index 4e644501..4baaf4c2 100644 --- a/src/components/Note/UnknownNote.tsx +++ b/src/components/Note/UnknownNote.tsx @@ -5,6 +5,7 @@ import ClientSelect from '../ClientSelect' import { extractBookMetadata } from '@/lib/bookstr-parser' import { ExtendedKind } from '@/constants' import { useMemo } from 'react' +import EventViewer from './EventViewer' export default function UnknownNote({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() @@ -21,21 +22,24 @@ export default function UnknownNote({ event, className }: { event: Event; classN return (
-
{t('Cannot handle event of kind k', { k: event.kind })}
- {isBookstrEvent && ( -
- {bookMetadata.type && Type: {bookMetadata.type}} - {bookMetadata.book && Book: {formatBookName(bookMetadata.book)}} - {bookMetadata.chapter && Chapter: {bookMetadata.chapter}} - {bookMetadata.verse && Verse: {bookMetadata.verse}} - {bookMetadata.version && Version: {bookMetadata.version.toUpperCase()}} -
- )} - +
+
{t('Cannot handle event of kind k', { k: event.kind })}
+ {isBookstrEvent && ( +
+ {bookMetadata.type && Type: {bookMetadata.type}} + {bookMetadata.book && Book: {formatBookName(bookMetadata.book)}} + {bookMetadata.chapter && Chapter: {bookMetadata.chapter}} + {bookMetadata.verse && Verse: {bookMetadata.verse}} + {bookMetadata.version && Version: {bookMetadata.version.toUpperCase()}} +
+ )} + +
+
) } diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 203db7bc..424ce7aa 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -612,9 +612,10 @@ export function useMenuActions({ label: t('Share with Jumble'), onClick: () => { const noteId = getNoteBech32Id(event) - // Only include context for discussions page, use plain /notes/{id} for others - const path = currentPrimaryPage === 'discussions' - ? `/discussions/notes/${noteId}` + // Contextual URL when on Spells (e.g. discussions faux-spell); plain /notes/{id} otherwise + const path = + currentPrimaryPage === 'spells' + ? `/spells/notes/${noteId}` : `/notes/${noteId}` const jumbleUrl = `https://jumble.imwald.eu${path}` navigator.clipboard.writeText(jumbleUrl) diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index ec802be5..ef50c151 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -37,8 +37,8 @@ const NotificationList = forwardRef( ref ) => { const { t } = useTranslation() - const { current, display } = usePrimaryPage() - const active = useMemo(() => current === 'notifications' && display, [current, display]) + const { display } = usePrimaryPage() + const active = display const { pubkey, relayList } = useNostr() const { notificationListStyle } = useUserPreferences() const { favoriteRelays } = useFavoriteRelays() @@ -142,8 +142,6 @@ const NotificationList = forwardRef( ) useEffect(() => { - if (current !== 'notifications') return - if (!pubkey) { setUntil(undefined) return @@ -234,7 +232,7 @@ const NotificationList = forwardRef( flushStatsBatch() // Flush any pending stats updates consecutiveEmptyRef.current = 0 // Reset counter on refresh } - }, [pubkey, refreshCount, filterKinds, current, flushStatsBatch]) + }, [pubkey, refreshCount, filterKinds, relayList, favoriteRelays, flushStatsBatch]) useEffect(() => { if (!active || !pubkey) return diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index a693c0af..d16ca44a 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -41,12 +41,30 @@ import logger from '@/lib/logger' import postEditorCache from '@/services/post-editor-cache.service' import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' -import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video, Film } from 'lucide-react' +import { + ImageUp, + ListTodo, + LoaderCircle, + MessageCircle, + MessagesSquare, + Settings, + Smile, + X, + Highlighter, + FileText, + Quote, + Upload, + Mic, + Music, + Video, + Film +} from 'lucide-react' import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' import client from '@/services/client.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' +import CreateThreadDialog from '@/pages/primary/DiscussionsPage/CreateThreadDialog' import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -116,6 +134,7 @@ export default function PostContent({ relays: [] }) const [minPow, setMinPow] = useState(0) + const [createThreadOpen, setCreateThreadOpen] = useState(false) const [mediaNoteKind, setMediaNoteKind] = useState(null) const [mediaImetaTags, setMediaImetaTags] = useState([]) const [mediaUrl, setMediaUrl] = useState('') @@ -2031,6 +2050,14 @@ export default function PostContent({ > + {/* Article dropdown - only show if has private relays for publication content */} {(hasPrivateRelaysAvailable || !isPublicationContent) && ( @@ -2403,6 +2430,19 @@ export default function PostContent({ + {createThreadOpen && ( + setCreateThreadOpen(false)} + onThreadCreated={() => { + discussionFeedCache.clearDiscussionsListCache() + setCreateThreadOpen(false) + close() + }} + /> + )}
) } diff --git a/src/components/Profile/ProfileArticles.tsx b/src/components/Profile/ProfileArticles.tsx deleted file mode 100644 index aed111ac..00000000 --- a/src/components/Profile/ProfileArticles.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { ExtendedKind } from '@/constants' -import { Event, kinds } from 'nostr-tools' -import { forwardRef, useMemo } from 'react' -import ProfileTimeline from './ProfileTimeline' - -const ARTICLE_KINDS = [ - kinds.LongFormArticle, - ExtendedKind.WIKI_ARTICLE_MARKDOWN, - ExtendedKind.WIKI_ARTICLE, - ExtendedKind.PUBLICATION, - kinds.Highlights -] - -interface ProfileArticlesProps { - pubkey: string - topSpace?: number - searchQuery?: string - kindFilter?: string - onEventsChange?: (events: Event[]) => void -} - -const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileArticlesProps>( - ({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { - const cacheKey = useMemo(() => `${pubkey}-articles`, [pubkey]) - - const getKindLabel = (kindValue: string) => { - if (!kindValue || kindValue === 'all') return 'articles, publications, or highlights' - const kindNum = parseInt(kindValue, 10) - if (kindNum === kinds.LongFormArticle) return 'long form articles' - if (kindNum === ExtendedKind.WIKI_ARTICLE_MARKDOWN) return 'wiki articles (markdown)' - if (kindNum === ExtendedKind.WIKI_ARTICLE) return 'wiki articles (asciidoc)' - if (kindNum === ExtendedKind.PUBLICATION) return 'publications' - if (kindNum === kinds.Highlights) return 'highlights' - return 'items' - } - - return ( - - ) - } -) - -ProfileArticles.displayName = 'ProfileArticles' - -export default ProfileArticles diff --git a/src/components/Profile/ProfileBookmarksAndHashtags.tsx b/src/components/Profile/ProfileBookmarksAndHashtags.tsx deleted file mode 100644 index f762ef7b..00000000 --- a/src/components/Profile/ProfileBookmarksAndHashtags.tsx +++ /dev/null @@ -1,964 +0,0 @@ -import { Event, kinds } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useState, forwardRef, useImperativeHandle } from 'react' -import { useTranslation } from 'react-i18next' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useNostr } from '@/providers/NostrProvider' -import client from '@/services/client.service' -import { queryService, replaceableEventService } from '@/services/client.service' -import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' -import logger from '@/lib/logger' -import { normalizeUrl } from '@/lib/url' -import NoteCard from '../NoteCard' -import { Skeleton } from '../ui/skeleton' - -type TabValue = 'bookmarks' | 'hashtags' | 'pins' -const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - -type BookmarksCacheEntry = { - events: Event[] - listEvent: Event | null - lastUpdated: number -} - -type HashtagsCacheEntry = { - events: Event[] - listEvent: Event | null - lastUpdated: number -} - -type PinsCacheEntry = { - events: Event[] - listEvent: Event | null - lastUpdated: number -} - -const bookmarksCache = new Map() -const hashtagsCache = new Map() -const pinsCache = new Map() - -const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, { - pubkey: string - initialTab?: TabValue - searchQuery?: string -}>(({ pubkey, initialTab = 'pins', searchQuery = '' }, ref) => { - const { t } = useTranslation() - const { pubkey: myPubkey } = useNostr() - const { favoriteRelays } = useFavoriteRelays() - const [bookmarkEvents, setBookmarkEvents] = useState([]) - const [hashtagEvents, setHashtagEvents] = useState([]) - const [pinEvents, setPinEvents] = useState([]) - const [loadingBookmarks, setLoadingBookmarks] = useState(true) - const [loadingHashtags, setLoadingHashtags] = useState(true) - const [loadingPins, setLoadingPins] = useState(true) - const [bookmarkListEvent, setBookmarkListEvent] = useState(null) - const [interestListEvent, setInterestListEvent] = useState(null) - const [pinListEvent, setPinListEvent] = useState(null) - - // Retry state for each tab - const [retryCountBookmarks, setRetryCountBookmarks] = useState(0) - const [retryCountHashtags, setRetryCountHashtags] = useState(0) - const [retryCountPins, setRetryCountPins] = useState(0) - const [isRetryingBookmarks, setIsRetryingBookmarks] = useState(false) - const [isRetryingHashtags, setIsRetryingHashtags] = useState(false) - const [isRetryingPins, setIsRetryingPins] = useState(false) - const [isRefreshing, setIsRefreshing] = useState(false) - const maxRetries = 3 - - // Build comprehensive relay list for fetching bookmark and interest list events - // Using the same comprehensive relay list construction as pin lists - const buildComprehensiveRelayList = useCallback(async () => { - const myRelayList = myPubkey ? await client.fetchRelayList(myPubkey) : { write: [], read: [] } - const allRelays = [ - ...(myRelayList.read || []), // User's inboxes (kind 10002) - ...(myRelayList.write || []), // User's outboxes (kind 10002) - ...(favoriteRelays || []), // User's favorite relays (kind 10012) - ...FAST_READ_RELAY_URLS, // Fast read relays - ...FAST_WRITE_RELAY_URLS // Fast write relays - ] - - const normalizedRelays = allRelays - .map(url => normalizeUrl(url)) - .filter((url): url is string => !!url) - - const comprehensiveRelays = Array.from(new Set(normalizedRelays)) - // Debug: Relay configuration for bookmark/interest list events - // console.log('[ProfileBookmarksAndHashtags] Using', comprehensiveRelays.length, 'relays for bookmark/interest list events:', comprehensiveRelays) - - return comprehensiveRelays - }, [myPubkey, favoriteRelays]) - - // Fetch bookmark list event and associated events - const fetchBookmarks = useCallback(async (isRetry = false, isRefresh = false) => { - const cacheKey = `${pubkey}-bookmarks` - - // Check cache first - const cachedEntry = bookmarksCache.get(cacheKey) - const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity - const isCacheFresh = cacheAge < CACHE_DURATION - - // If cache is fresh, show it immediately - if (isCacheFresh && cachedEntry && !isRetry && !isRefresh) { - // Add cached events to client cache so they're available in note view - cachedEntry.events.forEach(event => { - client.addEventToCache(event) - }) - setBookmarkEvents(cachedEntry.events) - setBookmarkListEvent(cachedEntry.listEvent) - setLoadingBookmarks(false) - // Still fetch in background to get updates - } else { - if (!isRetry && !isRefresh) { - setLoadingBookmarks(true) - setRetryCountBookmarks(0) - } else if (isRetry) { - setIsRetryingBookmarks(true) - } - } - - try { - const comprehensiveRelays = await buildComprehensiveRelayList() - - // Try to fetch bookmark list event from comprehensive relay list first - let bookmarkList = null - try { - const bookmarkListEvents = await queryService.fetchEvents(comprehensiveRelays, { - authors: [pubkey], - kinds: [10003], // Bookmark list kind - limit: 1 - }) - bookmarkList = bookmarkListEvents[0] || null - } catch (error) { - logger.component('ProfileBookmarksAndHashtags', 'Error fetching bookmark list from comprehensive relays, falling back to default method', { error: (error as Error).message }) - bookmarkList = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.BookmarkList) ?? null - } - - // console.log('[ProfileBookmarksAndHashtags] Bookmark list event:', bookmarkList) - setBookmarkListEvent(bookmarkList) - - if (bookmarkList && bookmarkList.tags.length > 0) { - // Extract event IDs from bookmark list - const eventIds = bookmarkList.tags - .filter(tag => tag[0] === 'e' && tag[1]) - .map(tag => tag[1]) - .reverse() // Reverse to show newest first - - // Extract 'a' tags for replaceable events (publications, articles, etc.) - const aTags = bookmarkList.tags - .filter(tag => tag[0] === 'a' && tag[1]) - .map(tag => tag[1]) - - // console.log('[ProfileBookmarksAndHashtags] Found', eventIds.length, 'bookmark event IDs and', aTags.length, 'a tags') - - // Fetch both regular events and replaceable events - const eventPromises: Promise[] = [] - - if (eventIds.length > 0) { - eventPromises.push(queryService.fetchEvents(comprehensiveRelays, { - ids: eventIds, - limit: 100 - })) - } - - if (aTags.length > 0) { - // For 'a' tags, we need to fetch replaceable events - // Parse the coordinate to get kind, pubkey, and d tag - const aTagFetches = aTags.map(async (aTag) => { - // aTag format: "kind:pubkey:d" - const parts = aTag.split(':') - if (parts.length < 2) return null - const kind = parseInt(parts[0]) - const pubkey = parts[1] - const d = parts[2] || '' - - const filter: any = { - authors: [pubkey], - kinds: [kind], - limit: 1 - } - if (d) { - filter['#d'] = [d] - } - - const events = await queryService.fetchEvents(comprehensiveRelays, [filter]) - return events[0] || null - }) - - eventPromises.push(Promise.all(aTagFetches).then(events => events.filter((e): e is Event => e !== null))) - } - - if (eventPromises.length > 0) { - try { - const eventArrays = await Promise.all(eventPromises) - const events = eventArrays.flat() - logger.debug('[ProfileBookmarksAndHashtags] Fetched', events.length, 'bookmark events') - - // Add all events to client cache so they're available immediately in note view - events.forEach(event => { - client.addEventToCache(event) - }) - - let finalEvents: Event[] - if (isRefresh) { - // For refresh, append new events and deduplicate - // Compute final events before setting state - const existingIds = new Set(bookmarkEvents.map(e => e.id)) - const newEvents = events.filter(event => !existingIds.has(event.id)) - finalEvents = [...newEvents, ...bookmarkEvents].sort((a, b) => b.created_at - a.created_at) - setBookmarkEvents(finalEvents) - } else { - finalEvents = events - setBookmarkEvents(events) - } - - // Update cache - bookmarksCache.set(cacheKey, { - events: finalEvents, - listEvent: bookmarkList, - lastUpdated: Date.now() - }) - } catch (error) { - logger.warn('[ProfileBookmarksAndHashtags] Error fetching bookmark events:', error) - setBookmarkEvents([]) - } - } else { - setBookmarkEvents([]) - // Update cache with empty result - bookmarksCache.set(cacheKey, { - events: [], - listEvent: bookmarkList, - lastUpdated: Date.now() - }) - } - } else { - setBookmarkEvents([]) - // Update cache with empty result - bookmarksCache.set(cacheKey, { - events: [], - listEvent: bookmarkList, - lastUpdated: Date.now() - }) - } - - // Reset retry count on successful fetch - if (isRetry) { - setRetryCountBookmarks(0) - } - } catch (error) { - logger.component('ProfileBookmarksAndHashtags', 'Error fetching bookmarks', { error: (error as Error).message, retryCount: isRetry ? retryCountBookmarks + 1 : 0 }) - - // If this is not a retry and we haven't exceeded max retries, schedule a retry - if (!isRetry && retryCountBookmarks < maxRetries) { - logger.debug('[ProfileBookmarksAndHashtags] Scheduling bookmark retry', { - attempt: retryCountBookmarks + 1, - maxRetries - }) - // Use shorter delays for initial retries, then exponential backoff - const delay = retryCountBookmarks === 0 ? 1000 : retryCountBookmarks === 1 ? 2000 : 3000 - setTimeout(() => { - setRetryCountBookmarks(prev => prev + 1) - fetchBookmarks(true) - }, delay) - } else { - setBookmarkEvents([]) - } - } finally { - setLoadingBookmarks(false) - setIsRetryingBookmarks(false) - if (isRefresh) { - setIsRefreshing(false) - } - } - }, [pubkey, buildComprehensiveRelayList, retryCountBookmarks, maxRetries]) - - // Internal function to actually fetch hashtags (without cache check) - const fetchHashtagsInternal = useCallback(async (isRetry = false, isRefresh = false, isBackgroundUpdate = false) => { - const cacheKey = `${pubkey}-hashtags` - - if (!isBackgroundUpdate) { - if (!isRetry && !isRefresh) { - setLoadingHashtags(true) - setRetryCountHashtags(0) - } else if (isRetry) { - setIsRetryingHashtags(true) - } - } - - try { - const comprehensiveRelays = await buildComprehensiveRelayList() - - // Try to fetch interest list event from comprehensive relay list first - let interestList = null - try { - const interestListEvents = await queryService.fetchEvents(comprehensiveRelays, { - authors: [pubkey], - kinds: [10015], // Interest list kind - limit: 1 - }) - interestList = interestListEvents[0] || null - } catch (error) { - logger.component('ProfileBookmarksAndHashtags', 'Error fetching interest list from comprehensive relays, falling back to default method', { error: (error as Error).message }) - interestList = await replaceableEventService.fetchReplaceableEvent(pubkey, 10015) ?? null - } - - // Only update interest list event if we're not doing a background update - if (!isBackgroundUpdate) { - setInterestListEvent(interestList) - } - - if (interestList && interestList.tags.length > 0) { - // Extract hashtags from interest list - const hashtags = interestList.tags - .filter((tag: string[]) => tag[0] === 't' && tag[1]) - .map((tag: string[]) => tag[1]) - - // console.log('[ProfileBookmarksAndHashtags] Found', hashtags.length, 'interest hashtags:', hashtags) - - if (hashtags.length > 0) { - try { - // Fetch recent events with these hashtags using the same comprehensive relay list - const events = await queryService.fetchEvents(comprehensiveRelays, { - kinds: [1], // Text notes - '#t': hashtags, - limit: 100 - }) - // console.log('[ProfileBookmarksAndHashtags] Fetched', events.length, 'hashtag events') - - // Add all events to client cache so they're available immediately in note view - events.forEach(event => { - client.addEventToCache(event) - }) - - let finalEvents: Event[] - if (isRefresh) { - // For refresh, append new events and deduplicate - // Compute final events before setting state - const existingIds = new Set(hashtagEvents.map(e => e.id)) - const newEvents = events.filter(event => !existingIds.has(event.id)) - finalEvents = [...newEvents, ...hashtagEvents].sort((a, b) => b.created_at - a.created_at) - setHashtagEvents(finalEvents) - } else if (isBackgroundUpdate) { - // For background update, merge with existing cached events - const existingIds = new Set(hashtagEvents.map(e => e.id)) - const newEvents = events.filter(event => !existingIds.has(event.id)) - if (newEvents.length > 0) { - finalEvents = [...newEvents, ...hashtagEvents].sort((a, b) => b.created_at - a.created_at) - setHashtagEvents(finalEvents) - } else { - // No new events, keep existing ones - finalEvents = hashtagEvents - } - } else { - finalEvents = events - setHashtagEvents(events) - } - - // Update cache only if we got events or if this is not a background update - if (!isBackgroundUpdate || (finalEvents && finalEvents.length > 0)) { - hashtagsCache.set(cacheKey, { - events: finalEvents, - listEvent: interestList, - lastUpdated: Date.now() - }) - } - } catch (error) { - logger.component('ProfileBookmarksAndHashtags', 'Error fetching hashtag events', { error: (error as Error).message }) - // Only clear events if this is not a background update - if (!isBackgroundUpdate) { - setHashtagEvents([]) - } - } - } else { - // Only clear events if this is not a background update - if (!isBackgroundUpdate) { - setHashtagEvents([]) - // Update cache with empty result - hashtagsCache.set(cacheKey, { - events: [], - listEvent: interestList, - lastUpdated: Date.now() - }) - } - } - } else { - // Only clear events if this is not a background update - if (!isBackgroundUpdate) { - setHashtagEvents([]) - // Update cache with empty result - hashtagsCache.set(cacheKey, { - events: [], - listEvent: interestList, - lastUpdated: Date.now() - }) - } - } - - // Reset retry count on successful fetch - if (isRetry) { - setRetryCountHashtags(0) - } - } catch (error) { - logger.component('ProfileBookmarksAndHashtags', 'Error fetching hashtags', { error: (error as Error).message, retryCount: isRetry ? retryCountHashtags + 1 : 0 }) - - // If this is not a retry and we haven't exceeded max retries, schedule a retry - if (!isRetry && retryCountHashtags < maxRetries && !isBackgroundUpdate) { - logger.debug('[ProfileBookmarksAndHashtags] Scheduling hashtag retry', { - attempt: retryCountHashtags + 1, - maxRetries - }) - // Use shorter delays for initial retries, then exponential backoff - const delay = retryCountHashtags === 0 ? 1000 : retryCountHashtags === 1 ? 2000 : 3000 - setTimeout(() => { - setRetryCountHashtags(prev => prev + 1) - fetchHashtags(true) - }, delay) - } else if (!isBackgroundUpdate) { - // Only clear events if this is not a background update - setHashtagEvents([]) - } - } finally { - // Only update loading state if this is not a background update - if (!isBackgroundUpdate) { - setLoadingHashtags(false) - setIsRetryingHashtags(false) - if (isRefresh) { - setIsRefreshing(false) - } - } - } - }, [pubkey, buildComprehensiveRelayList, retryCountHashtags, maxRetries, hashtagEvents]) - - // Main fetch function with cache check - const fetchHashtags = useCallback(async (isRetry = false, isRefresh = false) => { - const cacheKey = `${pubkey}-hashtags` - - // Check cache first - const cachedEntry = hashtagsCache.get(cacheKey) - const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity - const isCacheFresh = cacheAge < CACHE_DURATION - - // Track if we're doing a background update (cache is fresh, just checking for new events) - const isBackgroundUpdate = isCacheFresh && cachedEntry && !isRetry && !isRefresh - - // If cache is fresh, show it immediately and defer background fetch - if (isBackgroundUpdate) { - // Add cached events to client cache so they're available in note view - cachedEntry.events.forEach(event => { - client.addEventToCache(event) - }) - setHashtagEvents(cachedEntry.events) - setInterestListEvent(cachedEntry.listEvent) - setLoadingHashtags(false) - - // Defer background fetch to next tick to avoid blocking UI - setTimeout(() => { - // Run background fetch asynchronously without blocking - fetchHashtagsInternal(false, false, true).catch(() => { - // Silently fail background updates - }) - }, 100) // Small delay to let UI render first - return // Exit early, background fetch will run asynchronously - } - - // Not a background update, proceed with normal fetch - return fetchHashtagsInternal(isRetry, isRefresh, false) - }, [pubkey, fetchHashtagsInternal]) - - // Fetch pin list event and associated events - const fetchPins = useCallback(async (isRetry = false, isRefresh = false) => { - const cacheKey = `${pubkey}-pins` - - // Check cache first - const cachedEntry = pinsCache.get(cacheKey) - const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity - const isCacheFresh = cacheAge < CACHE_DURATION - - // If cache is fresh, show it immediately - if (isCacheFresh && cachedEntry && !isRetry && !isRefresh) { - // Add cached events to client cache so they're available in note view - cachedEntry.events.forEach(event => { - client.addEventToCache(event) - }) - setPinEvents(cachedEntry.events) - setPinListEvent(cachedEntry.listEvent) - setLoadingPins(false) - // Still fetch in background to get updates - } else { - if (!isRetry && !isRefresh) { - setLoadingPins(true) - setRetryCountPins(0) - } else if (isRetry) { - setIsRetryingPins(true) - } - } - - try { - const comprehensiveRelays = await buildComprehensiveRelayList() - - logger.component('ProfileBookmarksAndHashtags', 'Fetching pins for pubkey', { pubkey, relayCount: comprehensiveRelays.length }) - - // Try to fetch pin list event from comprehensive relay list first - let pinList = null - try { - const pinListEvents = await queryService.fetchEvents(comprehensiveRelays, { - authors: [pubkey], - kinds: [10001], // Pin list kind - limit: 1 - }) - pinList = pinListEvents[0] || null - logger.component('ProfileBookmarksAndHashtags', 'Found pin list event', { found: !!pinList }) - } catch (error) { - logger.component('ProfileBookmarksAndHashtags', 'Error fetching pin list from comprehensive relays, falling back to default method', { error: (error as Error).message }) - pinList = await replaceableEventService.fetchReplaceableEvent(pubkey, 10001) ?? null - logger.component('ProfileBookmarksAndHashtags', 'Fallback pin list event', { found: !!pinList }) - } - - // console.log('[ProfileBookmarksAndHashtags] Pin list event:', pinList) - setPinListEvent(pinList) - - if (pinList && pinList.tags.length > 0) { - // Extract event IDs from pin list - const eventIds = pinList.tags - .filter(tag => tag[0] === 'e' && tag[1]) - .map(tag => tag[1]) - .reverse() // Reverse to show newest first - - // Extract 'a' tags for replaceable events (publications, articles, etc.) - const aTags = pinList.tags - .filter(tag => tag[0] === 'a' && tag[1]) - .map(tag => tag[1]) - - // console.log('[ProfileBookmarksAndHashtags] Found', eventIds.length, 'pin event IDs and', aTags.length, 'a tags') - - // Fetch both regular events and replaceable events - const eventPromises: Promise[] = [] - - if (eventIds.length > 0) { - eventPromises.push(queryService.fetchEvents(comprehensiveRelays, { - ids: eventIds, - limit: 100 - })) - } - - if (aTags.length > 0) { - // For 'a' tags, we need to fetch replaceable events - // Parse the coordinate to get kind, pubkey, and d tag - const aTagFetches = aTags.map(async (aTag) => { - // aTag format: "kind:pubkey:d" - const parts = aTag.split(':') - if (parts.length < 2) return null - const kind = parseInt(parts[0]) - const pubkey = parts[1] - const d = parts[2] || '' - - const filter: any = { - authors: [pubkey], - kinds: [kind], - limit: 1 - } - if (d) { - filter['#d'] = [d] - } - - const events = await queryService.fetchEvents(comprehensiveRelays, [filter]) - return events[0] || null - }) - - eventPromises.push(Promise.all(aTagFetches).then(events => events.filter((e): e is Event => e !== null))) - } - - if (eventPromises.length > 0) { - try { - const eventArrays = await Promise.all(eventPromises) - const events = eventArrays.flat() - logger.debug('[ProfileBookmarksAndHashtags] Fetched', events.length, 'pin events') - - // Add all events to client cache so they're available immediately in note view - events.forEach(event => { - client.addEventToCache(event) - }) - - let finalEvents: Event[] - if (isRefresh) { - // For refresh, append new events and deduplicate - // Compute final events before setting state - const existingIds = new Set(pinEvents.map(e => e.id)) - const newEvents = events.filter(event => !existingIds.has(event.id)) - finalEvents = [...newEvents, ...pinEvents].sort((a, b) => b.created_at - a.created_at) - setPinEvents(finalEvents) - } else { - finalEvents = events - setPinEvents(events) - } - - // Update cache - pinsCache.set(cacheKey, { - events: finalEvents, - listEvent: pinList, - lastUpdated: Date.now() - }) - } catch (error) { - logger.warn('[ProfileBookmarksAndHashtags] Error fetching pin events:', error) - setPinEvents([]) - } - } else { - setPinEvents([]) - // Update cache with empty result - pinsCache.set(cacheKey, { - events: [], - listEvent: pinList, - lastUpdated: Date.now() - }) - } - } else { - setPinEvents([]) - // Update cache with empty result - pinsCache.set(cacheKey, { - events: [], - listEvent: pinList, - lastUpdated: Date.now() - }) - } - - // Reset retry count on successful fetch - if (isRetry) { - setRetryCountPins(0) - } - } catch (error) { - logger.component('ProfileBookmarksAndHashtags', 'Error fetching pins', { error: (error as Error).message, retryCount: isRetry ? retryCountPins + 1 : 0 }) - - // If this is not a retry and we haven't exceeded max retries, schedule a retry - if (!isRetry && retryCountPins < maxRetries) { - logger.debug('[ProfileBookmarksAndHashtags] Scheduling pin retry', { - attempt: retryCountPins + 1, - maxRetries - }) - // Use shorter delays for initial retries, then exponential backoff - const delay = retryCountPins === 0 ? 1000 : retryCountPins === 1 ? 2000 : 3000 - setTimeout(() => { - setRetryCountPins(prev => prev + 1) - fetchPins(true) - }, delay) - } else { - setPinEvents([]) - } - } finally { - setLoadingPins(false) - setIsRetryingPins(false) - if (isRefresh) { - setIsRefreshing(false) - } - } - }, [pubkey, buildComprehensiveRelayList, retryCountPins, maxRetries]) - - - // Expose refresh function to parent component - const refresh = useCallback(() => { - // Clear all caches on refresh - bookmarksCache.delete(`${pubkey}-bookmarks`) - hashtagsCache.delete(`${pubkey}-hashtags`) - pinsCache.delete(`${pubkey}-pins`) - - setRetryCountBookmarks(0) - setRetryCountHashtags(0) - setRetryCountPins(0) - setIsRefreshing(true) - fetchBookmarks(false, true) // isRetry = false, isRefresh = true - fetchHashtags(false, true) // isRetry = false, isRefresh = true - fetchPins(false, true) // isRetry = false, isRefresh = true - }, [pubkey, fetchBookmarks, fetchHashtags, fetchPins]) - - useImperativeHandle(ref, () => ({ - refresh - }), [refresh]) - - // Fetch data when component mounts or pubkey changes - delay slightly to avoid race conditions - useEffect(() => { - if (pubkey) { - // Small delay to stagger initial fetches and allow relay list cache to populate - const timeoutId = setTimeout(() => { - fetchBookmarks() - fetchHashtags() - fetchPins() - }, 200) // 200ms delay (longest since this component does 3 fetches) to allow previous fetches to populate cache - return () => clearTimeout(timeoutId) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pubkey]) // Only depend on pubkey - fetch functions are stable from useCallback - - // Check if the requested tab has content - const hasContent = useMemo(() => { - switch (initialTab) { - case 'pins': - return pinListEvent || loadingPins - case 'bookmarks': - return bookmarkListEvent || loadingBookmarks - case 'hashtags': - return interestListEvent || loadingHashtags - default: - return false - } - }, [initialTab, pinListEvent, bookmarkListEvent, interestListEvent, loadingPins, loadingBookmarks, loadingHashtags]) - - // Render loading state for the specific tab - const isLoading = useMemo(() => { - switch (initialTab) { - case 'pins': - return loadingPins || isRetryingPins - case 'bookmarks': - return loadingBookmarks || isRetryingBookmarks - case 'hashtags': - return loadingHashtags || isRetryingHashtags - default: - return false - } - }, [initialTab, loadingPins, loadingBookmarks, loadingHashtags, isRetryingPins, isRetryingBookmarks, isRetryingHashtags]) - - // Get retry info for current tab - const getRetryInfo = () => { - switch (initialTab) { - case 'pins': - return { isRetrying: isRetryingPins, retryCount: retryCountPins } - case 'bookmarks': - return { isRetrying: isRetryingBookmarks, retryCount: retryCountBookmarks } - case 'hashtags': - return { isRetrying: isRetryingHashtags, retryCount: retryCountHashtags } - default: - return { isRetrying: false, retryCount: 0 } - } - } - - const { isRetrying, retryCount } = getRetryInfo() - - // Filter events based on search query for each tab - const filteredBookmarkEvents = useMemo(() => { - if (!searchQuery.trim()) return bookmarkEvents - - const query = searchQuery.toLowerCase() - return bookmarkEvents.filter(event => - event.content.toLowerCase().includes(query) || - event.tags.some(tag => - tag.length > 1 && tag[1]?.toLowerCase().includes(query) - ) - ) - }, [bookmarkEvents, searchQuery]) - - const filteredHashtagEvents = useMemo(() => { - if (!searchQuery.trim()) return hashtagEvents - - const query = searchQuery.toLowerCase() - return hashtagEvents.filter(event => - event.content.toLowerCase().includes(query) || - event.tags.some(tag => - tag.length > 1 && tag[1]?.toLowerCase().includes(query) - ) - ) - }, [hashtagEvents, searchQuery]) - - const filteredPinEvents = useMemo(() => { - if (!searchQuery.trim()) return pinEvents - - const query = searchQuery.toLowerCase() - return pinEvents.filter(event => - event.content.toLowerCase().includes(query) || - event.tags.some(tag => - tag.length > 1 && tag[1]?.toLowerCase().includes(query) - ) - ) - }, [pinEvents, searchQuery]) - - if (isLoading) { - return ( -
- {isRetrying && retryCount > 0 && ( -
- Retrying... ({retryCount}/{maxRetries}) -
- )} - {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) - } - - // If no content available for this tab, don't render anything - if (!hasContent) { - return null - } - - // Render content based on initial tab - const renderContent = () => { - if (initialTab === 'pins') { - if (isRefreshing) { - return ( -
- 🔄 Refreshing pins... -
- ) - } - if (loadingPins) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) - } - - if (pinEvents.length === 0) { - return ( -
- {t('No pins found')} -
- ) - } - - if (filteredPinEvents.length === 0 && searchQuery.trim()) { - return ( -
- No pins match your search -
- ) - } - - return ( -
- {searchQuery.trim() && ( -
- {filteredPinEvents.length} of {pinEvents.length} pins -
- )} -
- {filteredPinEvents.map((event) => ( - - ))} -
-
- ) - } - - if (initialTab === 'bookmarks') { - if (isRefreshing) { - return ( -
- 🔄 Refreshing bookmarks... -
- ) - } - if (loadingBookmarks) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) - } - - if (bookmarkEvents.length === 0) { - return ( -
- {t('No bookmarks found')} -
- ) - } - - if (filteredBookmarkEvents.length === 0 && searchQuery.trim()) { - return ( -
- No bookmarks match your search -
- ) - } - - return ( -
- {searchQuery.trim() && ( -
- {filteredBookmarkEvents.length} of {bookmarkEvents.length} bookmarks -
- )} -
- {filteredBookmarkEvents.map((event) => ( - - ))} -
-
- ) - } - - if (initialTab === 'hashtags') { - if (isRefreshing) { - return ( -
- 🔄 Refreshing interests... -
- ) - } - if (loadingHashtags) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) - } - - if (hashtagEvents.length === 0) { - return ( -
- {t('No interest-related content found')} -
- ) - } - - if (filteredHashtagEvents.length === 0 && searchQuery.trim()) { - return ( -
- No interests match your search -
- ) - } - - return ( -
- {searchQuery.trim() && ( -
- {filteredHashtagEvents.length} of {hashtagEvents.length} interests -
- )} -
- {filteredHashtagEvents.map((event) => ( - - ))} -
-
- ) - } - - return null - } - - return renderContent() -}) - -ProfileBookmarksAndHashtags.displayName = 'ProfileBookmarksAndHashtags' - -export default ProfileBookmarksAndHashtags diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx new file mode 100644 index 00000000..b325f423 --- /dev/null +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -0,0 +1,195 @@ +import NoteCard from '@/components/NoteCard' +import ProfileSearchBar from '@/components/ui/ProfileSearchBar' +import RetroRefreshButton from '@/components/ui/RetroRefreshButton' +import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants' +import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { useProfilePins } from '@/hooks/useProfilePins' +import { useProfileTimeline } from '@/hooks/useProfileTimeline' +import { useZap } from '@/providers/ZapProvider' +import { Event } from 'nostr-tools' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Skeleton } from '@/components/ui/skeleton' + +const INITIAL_SHOW_COUNT = 25 +const LOAD_MORE_COUNT = 25 + +const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { + const { t } = useTranslation() + const { zapReplyThreshold } = useZap() + const [searchQuery, setSearchQuery] = useState('') + const [isRefreshing, setIsRefreshing] = useState(false) + const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) + const bottomRef = useRef(null) + + const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey) + + const filterPredicate = useCallback( + (event: Event) => { + if (event.kind === ExtendedKind.ZAP_RECEIPT) { + const zapInfo = getZapInfoFromEvent(event) + if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { + return false + } + } + return true + }, + [zapReplyThreshold] + ) + + const cacheKey = useMemo(() => `${pubkey}-profile-unified-${zapReplyThreshold}`, [pubkey, zapReplyThreshold]) + + const { events: timelineEvents, isLoading: loadingTimeline, refresh: refreshTimeline } = useProfileTimeline({ + pubkey, + cacheKey, + kinds: PROFILE_FEED_KINDS, + limit: 200, + filterPredicate + }) + + const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents]) + + const restTimeline = useMemo( + () => timelineEvents.filter((e) => !pinIds.has(e.id)), + [timelineEvents, pinIds] + ) + + const applySearch = useCallback( + (events: Event[]) => { + const q = searchQuery.trim().toLowerCase() + if (!q) return events + return events.filter((event) => { + if (event.content.toLowerCase().includes(q)) return true + return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q)) + }) + }, + [searchQuery] + ) + + const filteredPins = useMemo(() => applySearch(pinEvents), [pinEvents, applySearch]) + const filteredRest = useMemo(() => applySearch(restTimeline), [restTimeline, applySearch]) + + const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) + + useEffect(() => { + setShowCount(INITIAL_SHOW_COUNT) + }, [searchQuery, pubkey]) + + useEffect(() => { + if (!loadingPins && !loadingTimeline) { + setIsRefreshing(false) + } + }, [loadingPins, loadingTimeline]) + + const refreshAll = useCallback(() => { + setIsRefreshing(true) + refreshPins() + refreshTimeline() + }, [refreshPins, refreshTimeline]) + + useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) + + const displayedEvents = useMemo( + () => mergedDisplay.slice(0, showCount), + [mergedDisplay, showCount] + ) + + useEffect(() => { + if (!bottomRef.current || displayedEvents.length >= mergedDisplay.length) return + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && displayedEvents.length < mergedDisplay.length) { + setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, mergedDisplay.length)) + } + }, + { threshold: 0.1 } + ) + observer.observe(bottomRef.current) + return () => observer.disconnect() + }, [displayedEvents.length, mergedDisplay.length]) + + const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0 + + if (loading) { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) + } + + if (!mergedDisplay.length && !loadingPins && !loadingTimeline) { + return ( +
+
+ + +
+
+ {searchQuery.trim() ? t('No posts match your search') : t('No posts found')} +
+
+ ) + } + + return ( +
+
+ + +
+ {isRefreshing && ( +
🔄 {t('Refreshing posts...')}
+ )} + {searchQuery.trim() && ( +
+ {t('Showing {{filtered}} of {{total}} items', { + filtered: displayedEvents.length, + total: mergedDisplay.length + })} +
+ )} +
+ {displayedEvents.map((event, index) => ( +
+ {index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && ( +
+ {t('Posts')} +
+ )} + +
+ ))} +
+ {displayedEvents.length < mergedDisplay.length && ( +
+
{t('Loading more...')}
+
+ )} +
+ ) +}) + +ProfileFeedWithPins.displayName = 'ProfileFeedWithPins' + +export default ProfileFeedWithPins diff --git a/src/components/Profile/ProfileInteractions.tsx b/src/components/Profile/ProfileInteractions.tsx deleted file mode 100644 index daf02965..00000000 --- a/src/components/Profile/ProfileInteractions.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import NoteCard from '@/components/NoteCard' -import { Skeleton } from '@/components/ui/skeleton' -import { ExtendedKind } from '@/constants' -import { getZapInfoFromEvent } from '@/lib/event-metadata' -import { Event, kinds } from 'nostr-tools' -import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef, useCallback } from 'react' -import { queryService } from '@/services/client.service' -import { FAST_READ_RELAY_URLS } from '@/constants' -import { normalizeUrl } from '@/lib/url' -import { useZap } from '@/providers/ZapProvider' -import logger from '@/lib/logger' - -const INITIAL_SHOW_COUNT = 25 -const LOAD_MORE_COUNT = 25 -const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - -type InteractionsCacheEntry = { - events: Event[] - lastUpdated: number -} - -const interactionsCache = new Map() - -interface ProfileInteractionsProps { - accountPubkey: string - profilePubkey: string - topSpace?: number - searchQuery?: string - onEventsChange?: (events: Event[]) => void -} - -const ProfileInteractions = forwardRef< - { refresh: () => void; getEvents?: () => Event[] }, - ProfileInteractionsProps ->( - ( - { - accountPubkey, - profilePubkey, - topSpace, - searchQuery = '', - onEventsChange - }, - ref - ) => { - const { zapReplyThreshold } = useZap() - const [isRefreshing, setIsRefreshing] = useState(false) - const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) - const [events, setEvents] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [refreshToken, setRefreshToken] = useState(0) - const bottomRef = useRef(null) - - // Create cache key based on account and profile pubkeys - const cacheKey = useMemo(() => `${accountPubkey}-${profilePubkey}-${zapReplyThreshold}`, [accountPubkey, profilePubkey, zapReplyThreshold]) - - const fetchInteractions = useCallback(async () => { - // Check cache first - const cachedEntry = interactionsCache.get(cacheKey) - const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity - const isCacheFresh = cacheAge < CACHE_DURATION - - // If cache is fresh, show it immediately - if (isCacheFresh && cachedEntry) { - setEvents(cachedEntry.events) - setIsLoading(false) - // Still fetch in background to get updates - } else { - setIsLoading(!cachedEntry) - } - try { - const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) - - // Fetch events where accountPubkey interacted with profilePubkey - // 1. Replies: accountPubkey replied to profilePubkey's notes - // 2. Zaps: accountPubkey zapped profilePubkey - // 3. Mentions: accountPubkey mentioned profilePubkey - // 4. Replies to accountPubkey: profilePubkey replied to accountPubkey's notes - - const filters: any[] = [] - - // Get profilePubkey's notes to find replies to them - const profileNotes = await queryService.fetchEvents(relayUrls, [{ - authors: [profilePubkey], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION], - limit: 100 - }]) - - const profileNoteIds = profileNotes.map(e => e.id) - - // Replies from accountPubkey to profilePubkey's notes - if (profileNoteIds.length > 0) { - filters.push({ - authors: [accountPubkey], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], - '#e': profileNoteIds, - limit: 100 - }) - } - - // Zaps from accountPubkey to profilePubkey - filters.push({ - authors: [accountPubkey], - kinds: [kinds.Zap], - '#p': [profilePubkey], - limit: 100 - }) - - // Mentions: accountPubkey mentioned profilePubkey - filters.push({ - authors: [accountPubkey], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE], - '#p': [profilePubkey], - limit: 100 - }) - - // Get accountPubkey's notes to find replies from profilePubkey - const accountNotes = await queryService.fetchEvents(relayUrls, [{ - authors: [accountPubkey], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION], - limit: 100 - }]) - - const accountNoteIds = accountNotes.map(e => e.id) - - // Replies from profilePubkey to accountPubkey's notes - if (accountNoteIds.length > 0) { - filters.push({ - authors: [profilePubkey], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], - '#e': accountNoteIds, - limit: 100 - }) - } - - // Zaps from profilePubkey to accountPubkey - filters.push({ - authors: [profilePubkey], - kinds: [kinds.Zap], - '#p': [accountPubkey], - limit: 100 - }) - - // Mentions: profilePubkey mentioned accountPubkey - filters.push({ - authors: [profilePubkey], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE], - '#p': [accountPubkey], - limit: 100 - }) - - const allEvents = await queryService.fetchEvents(relayUrls, filters) - - // Deduplicate and filter - const seenIds = new Set() - const uniqueEvents = allEvents.filter(event => { - if (seenIds.has(event.id)) return false - seenIds.add(event.id) - - // Filter zap receipts below threshold - if (event.kind === ExtendedKind.ZAP_RECEIPT) { - const zapInfo = getZapInfoFromEvent(event) - if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { - return false - } - } - - return true - }) - - // Sort by created_at descending - uniqueEvents.sort((a, b) => b.created_at - a.created_at) - - // Update cache - interactionsCache.set(cacheKey, { - events: uniqueEvents, - lastUpdated: Date.now() - }) - - setEvents(uniqueEvents) - } catch (error) { - logger.error('Failed to fetch interactions', error) - setEvents([]) - } finally { - setIsLoading(false) - setIsRefreshing(false) - } - }, [accountPubkey, profilePubkey, zapReplyThreshold, cacheKey]) - - useEffect(() => { - if (!accountPubkey || !profilePubkey) return - fetchInteractions() - }, [accountPubkey, profilePubkey, refreshToken, fetchInteractions]) - - useEffect(() => { - onEventsChange?.(events) - }, [events, onEventsChange]) - - useImperativeHandle( - ref, - () => ({ - refresh: () => { - setIsRefreshing(true) - // Clear cache on refresh - interactionsCache.delete(cacheKey) - setRefreshToken((prev) => prev + 1) - }, - getEvents: () => events - }), - [events] - ) - - const filteredEvents = useMemo(() => { - if (!searchQuery.trim()) { - return events - } - const query = searchQuery.toLowerCase().trim() - return events.filter((event) => { - const contentLower = event.content.toLowerCase() - if (contentLower.includes(query)) return true - return event.tags.some((tag) => { - if (tag.length <= 1) return false - const tagValue = tag[1] - return tagValue && tagValue.toLowerCase().includes(query) - }) - }) - }, [events, searchQuery]) - - // Reset showCount when filters change - useEffect(() => { - setShowCount(INITIAL_SHOW_COUNT) - }, [searchQuery]) - - // Pagination: slice to showCount for display - const displayedEvents = useMemo(() => { - return filteredEvents.slice(0, showCount) - }, [filteredEvents, showCount]) - - // IntersectionObserver for infinite scroll - useEffect(() => { - if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { - setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) - } - }, - { threshold: 0.1 } - ) - - observer.observe(bottomRef.current) - - return () => { - observer.disconnect() - } - }, [displayedEvents.length, filteredEvents.length]) - - if (!accountPubkey || !profilePubkey) { - return ( -
-
No interactions to show
-
- ) - } - - if (isLoading && events.length === 0) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) - } - - if (!filteredEvents.length && !isLoading) { - return ( -
-
- {searchQuery.trim() ? 'No interactions match your search' : 'No interactions found'} -
-
- ) - } - - return ( -
- {isRefreshing && ( -
🔄 Refreshing interactions...
- )} - {searchQuery.trim() && ( -
- Showing {displayedEvents.length} of {filteredEvents.length} interactions -
- )} -
- {displayedEvents.map((event) => ( - - ))} -
- {displayedEvents.length < filteredEvents.length && ( -
-
Loading more...
-
- )} -
- ) - } -) - -ProfileInteractions.displayName = 'ProfileInteractions' - -export default ProfileInteractions - diff --git a/src/components/Profile/ProfileMedia.tsx b/src/components/Profile/ProfileMedia.tsx deleted file mode 100644 index f6eae378..00000000 --- a/src/components/Profile/ProfileMedia.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Event } from 'nostr-tools' -import { forwardRef, useMemo } from 'react' -import { ExtendedKind } from '@/constants' -import ProfileTimeline from './ProfileTimeline' - -const MEDIA_KIND_LIST = [ - ExtendedKind.PICTURE, - ExtendedKind.VIDEO, - ExtendedKind.SHORT_VIDEO -] - -interface ProfileMediaProps { - pubkey: string - topSpace?: number - searchQuery?: string - kindFilter?: string - onEventsChange?: (events: Event[]) => void -} - -const ProfileMedia = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileMediaProps>( - ({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { - const cacheKey = useMemo(() => `${pubkey}-media`, [pubkey]) - - const getKindLabel = (kindValue: string) => { - if (!kindValue || kindValue === 'all') return 'media items' - const kindNum = parseInt(kindValue, 10) - if (kindNum === ExtendedKind.PICTURE) return 'photos' - if (kindNum === ExtendedKind.VIDEO) return 'videos' - if (kindNum === ExtendedKind.SHORT_VIDEO) return 'short videos' - if (kindNum === ExtendedKind.VOICE) return 'voice posts' - if (kindNum === ExtendedKind.VOICE_COMMENT) return 'voice comments' - return 'media' - } - - return ( - - ) - } -) - -ProfileMedia.displayName = 'ProfileMedia' - -export default ProfileMedia - diff --git a/src/components/Profile/ProfileNotes.tsx b/src/components/Profile/ProfileNotes.tsx deleted file mode 100644 index a7abef25..00000000 --- a/src/components/Profile/ProfileNotes.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { ExtendedKind } from '@/constants' -import { Event } from 'nostr-tools' -import { forwardRef, useMemo, useEffect, useImperativeHandle, useState, useRef } from 'react' -import { useProfileNotesTimeline } from '@/hooks/useProfileNotesTimeline' -import NoteCard from '@/components/NoteCard' -import { Skeleton } from '@/components/ui/skeleton' - -const INITIAL_SHOW_COUNT = 25 -const LOAD_MORE_COUNT = 25 - -const NOTES_KIND_LIST = [ - ExtendedKind.PUBLICATION_CONTENT, // 30041 - ExtendedKind.CITATION_INTERNAL, // 30 - ExtendedKind.CITATION_EXTERNAL, // 31 - ExtendedKind.CITATION_HARDCOPY, // 32 - ExtendedKind.CITATION_PROMPT // 33 -] - -interface ProfileNotesProps { - pubkey: string - topSpace?: number - searchQuery?: string - kindFilter?: string - onEventsChange?: (events: Event[]) => void -} - -const ProfileNotes = forwardRef<{ refresh: () => void; getEvents?: () => Event[] }, ProfileNotesProps>( - ({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { - const cacheKey = useMemo(() => `${pubkey}-notes`, [pubkey]) - const [isRefreshing, setIsRefreshing] = useState(false) - const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) - const bottomRef = useRef(null) - - const { events: timelineEvents, isLoading, refresh } = useProfileNotesTimeline({ - pubkey, - cacheKey, - kinds: NOTES_KIND_LIST, - limit: 200, - filterPredicate: undefined - }) - - useEffect(() => { - onEventsChange?.(timelineEvents) - }, [timelineEvents, onEventsChange]) - - useEffect(() => { - if (!isLoading) { - setIsRefreshing(false) - } - }, [isLoading]) - - useImperativeHandle( - ref, - () => ({ - refresh: () => { - setIsRefreshing(true) - refresh() - }, - getEvents: () => timelineEvents - }), - [refresh, timelineEvents] - ) - - const getKindLabel = (kindValue: string) => { - if (!kindValue || kindValue === 'all') return 'notes' - const kindNum = parseInt(kindValue, 10) - if (kindNum === ExtendedKind.PUBLICATION_CONTENT) return 'notes' - if (kindNum === ExtendedKind.CITATION_INTERNAL) return 'internal citations' - if (kindNum === ExtendedKind.CITATION_EXTERNAL) return 'external citations' - if (kindNum === ExtendedKind.CITATION_HARDCOPY) return 'hardcopy citations' - if (kindNum === ExtendedKind.CITATION_PROMPT) return 'prompt citations' - return 'notes' - } - - const eventsFilteredByKind = useMemo(() => { - if (kindFilter === 'all') { - return timelineEvents - } - const kindNumber = parseInt(kindFilter, 10) - if (Number.isNaN(kindNumber)) { - return timelineEvents - } - return timelineEvents.filter((event) => event.kind === kindNumber) - }, [timelineEvents, kindFilter]) - - const filteredEvents = useMemo(() => { - if (!searchQuery.trim()) { - return eventsFilteredByKind - } - const query = searchQuery.toLowerCase().trim() - return eventsFilteredByKind.filter((event) => { - const contentLower = event.content.toLowerCase() - if (contentLower.includes(query)) return true - return event.tags.some((tag) => { - if (tag.length <= 1) return false - const tagValue = tag[1] - return tagValue && tagValue.toLowerCase().includes(query) - }) - }) - }, [eventsFilteredByKind, searchQuery]) - - // Reset showCount when filters change - useEffect(() => { - setShowCount(INITIAL_SHOW_COUNT) - }, [searchQuery, kindFilter, pubkey]) - - // Pagination: slice to showCount for display - const displayedEvents = useMemo(() => { - return filteredEvents.slice(0, showCount) - }, [filteredEvents, showCount]) - - // IntersectionObserver for infinite scroll - useEffect(() => { - if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { - setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) - } - }, - { threshold: 0.1 } - ) - - observer.observe(bottomRef.current) - - return () => { - observer.disconnect() - } - }, [displayedEvents.length, filteredEvents.length]) - - if (!pubkey) { - return ( -
-
No profile selected
-
- ) - } - - if (isLoading && timelineEvents.length === 0) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) - } - - if (!filteredEvents.length && !isLoading) { - return ( -
-
- {searchQuery.trim() ? 'No notes match your search' : 'No notes found'} -
-
- ) - } - - return ( -
- {isRefreshing && ( -
🔄 Refreshing notes...
- )} - {(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && ( -
- Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)} -
- )} -
- {displayedEvents.map((event) => ( - - ))} -
- {displayedEvents.length < filteredEvents.length && ( -
-
Loading more...
-
- )} -
- ) - } -) - -ProfileNotes.displayName = 'ProfileNotes' - -export default ProfileNotes - diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index b7789f31..44a77d8f 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -8,28 +8,15 @@ import ProfileBanner from '@/components/ProfileBanner' import ProfileOptions from '@/components/ProfileOptions' import ProfileZapButton from '@/components/ProfileZapButton' import PubkeyCopy from '@/components/PubkeyCopy' -import Tabs from '@/components/Tabs' -import RetroRefreshButton from '@/components/ui/RetroRefreshButton' -import ProfileSearchBar from '@/components/ui/ProfileSearchBar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { ExtendedKind } from '@/constants' import { useFetchProfile } from '@/hooks' -import { Event, kinds } from 'nostr-tools' +import { kinds, type NostrEvent } from 'nostr-tools' import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { toProfileEditor } from '@/lib/link' import { generateImageByPubkey } from '@/lib/pubkey' import { useSecondaryPage } from '@/PageManager' -import { toNoteList } from '@/lib/link' -import { parseAdvancedSearch } from '@/lib/search-parser' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { replaceableEventService } from '@/services/client.service' @@ -40,22 +27,17 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { FileText, Link, Film, Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code } from 'lucide-react' -import { useEffect, useMemo, useState, useRef } from 'react' +import { Link, Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import logger from '@/lib/logger' import NotFound from '../NotFound' import FollowedBy from './FollowedBy' -import ProfileFeed from './ProfileFeed' -import ProfileArticles from './ProfileArticles' -import ProfileBookmarksAndHashtags from './ProfileBookmarksAndHashtags' +import ProfileFeedWithPins from './ProfileFeedWithPins' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' -import ProfileMedia from './ProfileMedia' -import ProfileInteractions from './ProfileInteractions' -import ProfileNotes from './ProfileNotes' import { toFollowPacks } from '@/lib/link' import ZapDialog from '@/components/ZapDialog' import PaytoLink from '@/components/PaytoLink' @@ -72,8 +54,6 @@ import { nip66Service } from '@/services/nip66.service' import { normalizeUrl } from '@/lib/url' import type { TProfile } from '@/types' -type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes' - /** * Normalize lightning/LUD-16 authority to a canonical form for deduplication. * Handles "user@domain" and "user.domain" (dot variant) as the same address. @@ -180,7 +160,7 @@ export default function Profile({ id }: { id?: string }) { const { profile, isFetching } = useFetchProfile(id) const { pubkey: accountPubkey } = useNostr() const [paymentInfo, setPaymentInfo] = useState | null>(null) - const [profileEvent, setProfileEvent] = useState(undefined) + const [profileEvent, setProfileEvent] = useState(undefined) const [openZapDialog, setOpenZapDialog] = useState(false) const [openPublicMessageTo, setOpenPublicMessageTo] = useState(null) const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null) @@ -266,98 +246,7 @@ export default function Profile({ id }: { id?: string }) { fetchProfileEventData() }, [profile?.pubkey]) - const [activeTab, setActiveTab] = useState('posts') - const [searchQuery, setSearchQuery] = useState('') - const [articleKindFilter, setArticleKindFilter] = useState('all') - const [postKindFilter, setPostKindFilter] = useState('all') - const [mediaKindFilter, setMediaKindFilter] = useState('all') - const [notesKindFilter, setNotesKindFilter] = useState('all') - // Handle search in articles tab - parse advanced search parameters - const handleArticleSearch = (query: string) => { - if (activeTab === 'articles' && query.trim()) { - const searchParams = parseAdvancedSearch(query) - - // Build kinds array from filter - const kinds = articleKindFilter && articleKindFilter !== 'all' - ? [parseInt(articleKindFilter)] - : undefined - - // Note: Kind filter only available as URL parameter k=, not from search parser - const allKinds = kinds - - // Build URL with search parameters - // For now, if we have a d-tag, use that. Otherwise use advanced search - if (searchParams.dtag) { - // Use d-tag search if we have plain text - const url = toNoteList({ domain: searchParams.dtag, kinds: allKinds }) - push(url) - return - } else if (Object.keys(searchParams).length > 0) { - // Advanced search - we'll need to pass these as URL params - // For now, construct URL with all parameters - const urlParams = new URLSearchParams() - if (searchParams.title) { - if (Array.isArray(searchParams.title)) { - searchParams.title.forEach(t => urlParams.append('title', t)) - } else { - urlParams.set('title', searchParams.title) - } - } - if (searchParams.subject) { - if (Array.isArray(searchParams.subject)) { - searchParams.subject.forEach(s => urlParams.append('subject', s)) - } else { - urlParams.set('subject', searchParams.subject) - } - } - if (searchParams.description) { - if (Array.isArray(searchParams.description)) { - searchParams.description.forEach(d => urlParams.append('description', d)) - } else { - urlParams.set('description', searchParams.description) - } - } - if (searchParams.author) { - if (Array.isArray(searchParams.author)) { - searchParams.author.forEach(a => urlParams.append('author', a)) - } else { - urlParams.set('author', searchParams.author) - } - } - if (searchParams.type) { - if (Array.isArray(searchParams.type)) { - searchParams.type.forEach(t => urlParams.append('type', t)) - } else { - urlParams.set('type', searchParams.type) - } - } - // Note: Date searches, pubkey filters, and event filters removed - not supported - if (allKinds) { - allKinds.forEach((k: number) => urlParams.append('k', k.toString())) - } - - const url = `/notes?${urlParams.toString()}` - push(url) - return - } - } - setSearchQuery(query) - } - - // Refs for child components - const profileFeedRef = useRef<{ refresh: () => void }>(null) - const profileBookmarksRef = useRef<{ refresh: () => void }>(null) - const profileArticlesRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null) - const profileMediaRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null) - const profileInteractionsRef = useRef<{ refresh: () => void; getEvents?: () => Event[] }>(null) - const profileNotesRef = useRef<{ refresh: () => void; getEvents?: () => Event[] }>(null) - const [articleEvents, setArticleEvents] = useState([]) - const [postEvents, setPostEvents] = useState([]) - const [mediaEvents, setMediaEvents] = useState([]) - const [_interactionEvents, setInteractionEvents] = useState([]) - const [notesEvents, setNotesEvents] = useState([]) - const isFollowingYou = useMemo(() => { // This will be handled by the FollowedBy component return false @@ -424,71 +313,6 @@ export default function Profile({ id }: { id?: string }) { }) } - // Refresh functions for each tab - const handleRefresh = () => { - if (activeTab === 'posts') { - profileFeedRef.current?.refresh() - } else if (activeTab === 'articles') { - profileArticlesRef.current?.refresh() - } else if (activeTab === 'media') { - profileMediaRef.current?.refresh() - } else if (activeTab === 'you') { - profileInteractionsRef.current?.refresh() - } else if (activeTab === 'notes') { - profileNotesRef.current?.refresh() - } else { - profileBookmarksRef.current?.refresh() - } - } - - // Define tabs with refresh buttons - const tabs = useMemo(() => { - const baseTabs = [ - { - value: 'posts', - label: 'Posts' - }, - { - value: 'articles', - label: 'Articles' - }, - { - value: 'media', - label: 'Media' - }, - { - value: 'pins', - label: 'Pins' - }, - { - value: 'bookmarks', - label: 'Bookmarks' - }, - { - value: 'interests', - label: 'Interests' - } - ] - - // Add "My Notes" tab if viewing own profile - if (isSelf) { - baseTabs.push({ - value: 'notes', - label: 'My Notes' - }) - } - - // Add "You" tab if viewing another user's profile and logged in - if (!isSelf && accountPubkey) { - baseTabs.push({ - value: 'you', - label: 'You' - }) - } - - return baseTabs - }, [isSelf, accountPubkey]) - useEffect(() => { if (!profile?.pubkey) return @@ -501,18 +325,6 @@ export default function Profile({ id }: { id?: string }) { forceUpdateCache() }, [profile?.pubkey]) - // Listen for tab restoration from PageManager - useEffect(() => { - const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => { - if (e.detail.page === 'profile' && e.detail.tab) { - setActiveTab(e.detail.tab as ProfileTabValue) - } - } - window.addEventListener('restorePageTab', handleRestore as EventListener) - return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) - }, []) - - if (!profile && isFetching) { return ( <> @@ -750,198 +562,7 @@ export default function Profile({ id }: { id?: string }) { -
-
- { - setActiveTab(tab as ProfileTabValue) - // Dispatch tab change event for PageManager - window.dispatchEvent(new CustomEvent('pageTabChanged', { - detail: { page: 'profile', tab: tab } - })) - }} - threshold={800} - /> -
- - {activeTab === 'posts' && (() => { - const allCount = postEvents.length - const noteCount = postEvents.filter((event) => event.kind === kinds.ShortTextNote).length - const repostCount = postEvents.filter((event) => event.kind === kinds.Repost).length - const commentCount = postEvents.filter((event) => event.kind === ExtendedKind.COMMENT).length - const discussionCount = postEvents.filter((event) => event.kind === ExtendedKind.DISCUSSION).length - const pollCount = postEvents.filter((event) => event.kind === ExtendedKind.POLL).length - const superzapCount = postEvents.filter((event) => event.kind === ExtendedKind.ZAP_RECEIPT).length - const calendarEventCount = postEvents.filter( - (event) => - event.kind === ExtendedKind.CALENDAR_EVENT_TIME || - event.kind === ExtendedKind.CALENDAR_EVENT_DATE - ).length - - return ( - - ) - })()} - {activeTab === 'articles' && (() => { - const allCount = articleEvents.length - const longFormCount = articleEvents.filter((e) => e.kind === kinds.LongFormArticle).length - const wikiMarkdownCount = articleEvents.filter((e) => e.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN).length - const wikiAsciiDocCount = articleEvents.filter((e) => e.kind === ExtendedKind.WIKI_ARTICLE).length - const publicationCount = articleEvents.filter((e) => e.kind === ExtendedKind.PUBLICATION).length - const highlightsCount = articleEvents.filter((e) => e.kind === kinds.Highlights).length - - return ( - - ) - })()} - {activeTab === 'media' && (() => { - const allCount = mediaEvents.length - const pictureCount = mediaEvents.filter((event) => event.kind === ExtendedKind.PICTURE).length - const videoCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VIDEO).length - const shortVideoCount = mediaEvents.filter((event) => event.kind === ExtendedKind.SHORT_VIDEO).length - const voiceCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VOICE).length - const voiceCommentCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VOICE_COMMENT).length - - return ( - - ) - })()} - {activeTab === 'notes' && (() => { - const allCount = notesEvents.length - const publicationContentCount = notesEvents.filter((event) => event.kind === ExtendedKind.PUBLICATION_CONTENT).length - const internalCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_INTERNAL).length - const externalCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_EXTERNAL).length - const hardcopyCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_HARDCOPY).length - const promptCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_PROMPT).length - - return ( - - ) - })()} - -
-
- {activeTab === 'posts' && ( - - )} - {activeTab === 'articles' && ( - - )} - {activeTab === 'media' && ( - - )} - {(activeTab === 'pins' || activeTab === 'bookmarks' || activeTab === 'interests') && ( - - )} - {activeTab === 'notes' && ( - - )} - {activeTab === 'you' && accountPubkey && ( - - )} -
+ {openPublicMessageTo && ( navigate('discussions')} - active={display && current === 'discussions' && primaryViewType === null} + onClick={() => navigate('spells', { spell: 'discussions' })} + active={display && current === 'spells' && primaryViewType === null} > diff --git a/src/components/Sidebar/NotificationButton.tsx b/src/components/Sidebar/NotificationButton.tsx index 63b36187..f33f9958 100644 --- a/src/components/Sidebar/NotificationButton.tsx +++ b/src/components/Sidebar/NotificationButton.tsx @@ -1,18 +1,17 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { Bell } from 'lucide-react' import SidebarItem from './SidebarItem' -export default function NotificationsButton() { +export default function NotificationButton() { + const { navigate } = usePrimaryPage() const { checkLogin } = useNostr() - const { navigate, current, display } = usePrimaryPage() - const { primaryViewType } = usePrimaryNoteView() return ( checkLogin(() => navigate('notifications'))} - active={display && current === 'notifications' && primaryViewType === null} + title="notifications" + onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))} + active={false} > diff --git a/src/components/Sidebar/ProfileButton.tsx b/src/components/Sidebar/ProfileButton.tsx deleted file mode 100644 index 98c3392f..00000000 --- a/src/components/Sidebar/ProfileButton.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' -import { useNostr } from '@/providers/NostrProvider' -import { UserRound } from 'lucide-react' -import SidebarItem from './SidebarItem' - -export default function ProfileButton() { - const { navigate, current, display } = usePrimaryPage() - const { primaryViewType } = usePrimaryNoteView() - const { checkLogin } = useNostr() - - // Profile button is active when: - // 1. Profile is the current primary page AND there's no overlay (primaryViewType === null) - // 2. OR primaryViewType is 'profile' (overlay profile) - const isActive = - (display && current === 'profile' && primaryViewType === null) || - primaryViewType === 'profile' - - return ( - checkLogin(() => navigate('profile'))} - active={isActive} - > - - - ) -} diff --git a/src/components/Sidebar/SpellsButton.tsx b/src/components/Sidebar/SpellsButton.tsx index 58738136..1ad174b2 100644 --- a/src/components/Sidebar/SpellsButton.tsx +++ b/src/components/Sidebar/SpellsButton.tsx @@ -1,14 +1,17 @@ -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { Wand2 } from 'lucide-react' import SidebarItem from './SidebarItem' export default function SpellsButton() { const { navigate, current, display } = usePrimaryPage() - - const isActive = display && current === 'spells' + const { primaryViewType } = usePrimaryNoteView() return ( - navigate('spells')} active={isActive}> + navigate('spells')} + active={current === 'spells' && display && primaryViewType === null} + > ) diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index ffd03875..008ad91e 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -6,13 +6,12 @@ import KeyboardShortcutsHelpSidebarButton from './KeyboardShortcutsHelpSidebarBu import DiscussionsButton from './DiscussionsButton' import RelaysButton from './ExploreButton' import HomeButton from './HomeButton' -import NotificationsButton from './NotificationButton' +import NotificationButton from './NotificationButton' import PostButton from './PostButton' -import ProfileButton from './ProfileButton' import RssButton from './RssButton' -import SpellsButton from './SpellsButton' import SearchButton from './SearchButton' import SettingsButton from './SettingsButton' +import SpellsButton from './SpellsButton' import PaneModeToggle from './PaneModeToggle' import storage from '@/services/local-storage.service' @@ -36,11 +35,10 @@ export default function PrimaryPageSidebar() { - + - - {showRssFeed && } + {showRssFeed && } diff --git a/src/constants.ts b/src/constants.ts index 1238fa0f..d0b79aa7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -267,6 +267,28 @@ export const SUPPORTED_KINDS = [ ExtendedKind.APPLICATION_HANDLER_INFO ] +/** Kinds for profile feed and favorites-style feeds: supported kinds except reposts, publications, publication content, NIP-89 handlers. */ +export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter( + (k) => + k !== kinds.Repost && + k !== ExtendedKind.PUBLICATION && + k !== ExtendedKind.PUBLICATION_CONTENT && + k !== ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION && + k !== ExtendedKind.APPLICATION_HANDLER_INFO +) + +/** Order for faux-spells in the feed / spell picker. */ +export const FAUX_SPELL_ORDER = [ + 'favorite-relays', + 'notifications', + 'discussions', + 'following', + 'media', + 'interests', + 'bookmarks', + 'calendar' +] as const + export const URL_REGEX = /https?:\/\/[\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*]+(?:,[^\s.][\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*,]*)*[^\s.,;:'")\]}!?,。;:"'!?】)](?=\.|,\s|$|[^\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*,])/giu export const WS_URL_REGEX = diff --git a/src/hooks/useProfileNotesTimeline.tsx b/src/hooks/useProfileNotesTimeline.tsx deleted file mode 100644 index 6d73ca7b..00000000 --- a/src/hooks/useProfileNotesTimeline.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { useEffect, useMemo, useRef, useState, useCallback } from 'react' -import { Event } from 'nostr-tools' -import client from '@/services/client.service' -import { FAST_READ_RELAY_URLS } from '@/constants' -import { normalizeUrl } from '@/lib/url' -import { getPrivateRelayUrls } from '@/lib/private-relays' - -type ProfileNotesTimelineCacheEntry = { - events: Event[] - lastUpdated: number -} - -const timelineCache = new Map() -const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - cache is considered fresh for this long - -type UseProfileNotesTimelineOptions = { - pubkey: string - cacheKey: string - kinds: number[] - limit?: number - filterPredicate?: (event: Event) => boolean -} - -type UseProfileNotesTimelineResult = { - events: Event[] - isLoading: boolean - refresh: () => void -} - -function postProcessEvents( - rawEvents: Event[], - filterPredicate: ((event: Event) => boolean) | undefined, - limit: number -) { - const dedupMap = new Map() - rawEvents.forEach((evt) => { - if (!dedupMap.has(evt.id)) { - dedupMap.set(evt.id, evt) - } - }) - - let events = Array.from(dedupMap.values()) - if (filterPredicate) { - events = events.filter(filterPredicate) - } - events.sort((a, b) => b.created_at - a.created_at) - return events.slice(0, limit) -} - -export function useProfileNotesTimeline({ - pubkey, - cacheKey, - kinds, - limit = 200, - filterPredicate -}: UseProfileNotesTimelineOptions): UseProfileNotesTimelineResult { - const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) - const [events, setEvents] = useState(cachedEntry?.events ?? []) - const [isLoading, setIsLoading] = useState(!cachedEntry) - const [refreshToken, setRefreshToken] = useState(0) - const subscriptionRef = useRef<() => void>(() => {}) - - useEffect(() => { - let cancelled = false - - const subscribe = async () => { - // Check if we have fresh cached data - const cachedEntry = timelineCache.get(cacheKey) - const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity - const isCacheFresh = cacheAge < CACHE_DURATION - - // If cache is fresh, show it immediately and skip subscribing - if (isCacheFresh && cachedEntry) { - setEvents(cachedEntry.events) - setIsLoading(false) - // Still subscribe in background to get updates, but don't show loading - } else { - // Cache is stale or missing - show loading and fetch - setIsLoading(!cachedEntry) - } - - try { - // Get private relays (outbox + cache relays) for private notes - const privateRelayUrls = await getPrivateRelayUrls(pubkey) - const normalizedPrivateRelays = Array.from( - new Set( - privateRelayUrls - .map((url) => normalizeUrl(url)) - .filter((value): value is string => !!value) - ) - ) - - // Also include fast read relays as fallback - const fastReadRelays = Array.from( - new Set( - FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) - ) - ) - - // Build relay groups: private relays first, then fast read relays - const relayGroups: string[][] = [] - if (normalizedPrivateRelays.length > 0) { - relayGroups.push(normalizedPrivateRelays) - } - if (fastReadRelays.length > 0) { - relayGroups.push(fastReadRelays) - } - - if (cancelled) { - return - } - - const subRequests = relayGroups - .map((urls) => ({ - urls, - filter: { - authors: [pubkey], - kinds, - limit - } as any - })) - .filter((request) => request.urls.length) - - if (!subRequests.length) { - timelineCache.set(cacheKey, { - events: [], - lastUpdated: Date.now() - }) - setEvents([]) - setIsLoading(false) - return - } - - const { closer } = await client.subscribeTimeline( - subRequests, - { - onEvents: (fetchedEvents) => { - if (cancelled) return - const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit) - timelineCache.set(cacheKey, { - events: processed, - lastUpdated: Date.now() - }) - setEvents(processed) - setIsLoading(false) - }, - onNew: (evt) => { - if (cancelled) return - setEvents((prevEvents) => { - const combined = [evt as Event, ...prevEvents] - const processed = postProcessEvents(combined, filterPredicate, limit) - timelineCache.set(cacheKey, { - events: processed, - lastUpdated: Date.now() - }) - return processed - }) - } - }, - { needSort: true, useCache: false } // NO CACHING - stream raw from relays - ) - - subscriptionRef.current = () => closer() - } catch (error) { - if (!cancelled) { - setIsLoading(false) - } - } - } - - subscribe() - - return () => { - cancelled = true - subscriptionRef.current() - subscriptionRef.current = () => {} - } - }, [pubkey, cacheKey, JSON.stringify(kinds), limit, filterPredicate, refreshToken]) - - const refresh = useCallback(() => { - subscriptionRef.current() - subscriptionRef.current = () => {} - timelineCache.delete(cacheKey) - setIsLoading(true) - setRefreshToken((token) => token + 1) - }, [cacheKey]) - - return { - events, - isLoading, - refresh - } -} - diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx new file mode 100644 index 00000000..5702a1bd --- /dev/null +++ b/src/hooks/useProfilePins.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useState } from 'react' +import { Event } from 'nostr-tools' +import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' +import logger from '@/lib/logger' +import { normalizeUrl } from '@/lib/url' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import { queryService, replaceableEventService } from '@/services/client.service' + +const CACHE_DURATION = 5 * 60 * 1000 + +type PinsCacheEntry = { + events: Event[] + lastUpdated: number +} + +const pinsCache = new Map() + +function orderPinEvents(pinList: Event, eventsById: Map): Event[] { + const ordered: Event[] = [] + const seen = new Set() + + const eIds = pinList.tags + .filter((tag) => tag[0] === 'e' && tag[1]) + .map((tag) => tag[1]) + .reverse() + + for (const id of eIds) { + const ev = eventsById.get(id) + if (ev && !seen.has(ev.id)) { + ordered.push(ev) + seen.add(ev.id) + } + } + + const aTags = pinList.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]) + for (const coord of aTags) { + const ev = [...eventsById.values()].find((e) => { + const d = e.tags.find((t) => t[0] === 'd')?.[1] ?? '' + return `${e.kind}:${e.pubkey}:${d}` === coord + }) + if (ev && !seen.has(ev.id)) { + ordered.push(ev) + seen.add(ev.id) + } + } + + for (const ev of eventsById.values()) { + if (!seen.has(ev.id)) { + ordered.push(ev) + seen.add(ev.id) + } + } + + return ordered +} + +export function useProfilePins(pubkey: string | undefined) { + const { pubkey: myPubkey } = useNostr() + const { favoriteRelays } = useFavoriteRelays() + const [pinEvents, setPinEvents] = useState([]) + const [loadingPins, setLoadingPins] = useState(false) + + const buildComprehensiveRelayList = useCallback(async () => { + const myRelayList = myPubkey ? await client.fetchRelayList(myPubkey) : { write: [], read: [] } + const allRelays = [ + ...(myRelayList.read || []), + ...(myRelayList.write || []), + ...(favoriteRelays || []), + ...FAST_READ_RELAY_URLS, + ...FAST_WRITE_RELAY_URLS + ] + const normalized = allRelays.map((url) => normalizeUrl(url)).filter((url): url is string => !!url) + return Array.from(new Set(normalized)) + }, [myPubkey, favoriteRelays]) + + const loadPins = useCallback( + async (forceRefresh = false) => { + if (!pubkey) { + setPinEvents([]) + return + } + const cacheKey = `${pubkey}-pins-profile` + if (!forceRefresh) { + const cached = pinsCache.get(cacheKey) + if (cached && Date.now() - cached.lastUpdated < CACHE_DURATION) { + setPinEvents(cached.events) + cached.events.forEach((e) => client.addEventToCache(e)) + return + } + } + + setLoadingPins(true) + try { + const comprehensiveRelays = await buildComprehensiveRelayList() + let pinList: Event | null = null + try { + const pinListEvents = await queryService.fetchEvents(comprehensiveRelays, { + authors: [pubkey], + kinds: [10001], + limit: 1 + }) + pinList = pinListEvents[0] || null + } catch { + pinList = (await replaceableEventService.fetchReplaceableEvent(pubkey, 10001)) ?? null + } + + if (!pinList?.tags?.length) { + setPinEvents([]) + pinsCache.set(cacheKey, { events: [], lastUpdated: Date.now() }) + return + } + + const eventIds = pinList.tags.filter((tag) => tag[0] === 'e' && tag[1]).map((tag) => tag[1]) + const aTags = pinList.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]) + + const eventPromises: Promise[] = [] + if (eventIds.length > 0) { + eventPromises.push( + queryService.fetchEvents(comprehensiveRelays, { ids: eventIds, limit: 100 }) + ) + } + if (aTags.length > 0) { + const aTagFetches = aTags.map(async (aTag) => { + const parts = aTag.split(':') + if (parts.length < 2) return null + const kind = parseInt(parts[0], 10) + const author = parts[1] + const d = parts[2] || '' + const filter = d + ? { authors: [author], kinds: [kind], limit: 1, '#d': [d] as [string] } + : { authors: [author], kinds: [kind], limit: 1 } + const events = await queryService.fetchEvents(comprehensiveRelays, [filter]) + return events[0] || null + }) + eventPromises.push( + Promise.all(aTagFetches).then((events) => events.filter((e): e is Event => e !== null)) + ) + } + + const eventArrays = await Promise.all(eventPromises) + const flat = eventArrays.flat() + flat.forEach((e) => client.addEventToCache(e)) + + const byId = new Map() + for (const e of flat) { + byId.set(e.id, e) + } + + const ordered = orderPinEvents(pinList, byId) + setPinEvents(ordered) + pinsCache.set(cacheKey, { events: ordered, lastUpdated: Date.now() }) + } catch (e) { + logger.warn('[useProfilePins] Failed to load pins', e) + setPinEvents([]) + } finally { + setLoadingPins(false) + } + }, + [pubkey, buildComprehensiveRelayList] + ) + + useEffect(() => { + if (!pubkey) { + setPinEvents([]) + return + } + const t = setTimeout(() => void loadPins(false), 200) + return () => clearTimeout(t) + }, [pubkey, loadPins]) + + const refreshPins = useCallback(() => { + if (pubkey) { + pinsCache.delete(`${pubkey}-pins-profile`) + } + void loadPins(true) + }, [pubkey, loadPins]) + + return { pinEvents, loadingPins, refreshPins } +} diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx index 6c1bbcc3..53462f0f 100644 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -5,6 +5,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useSmartNoteNavigation } from '@/PageManager' import { toNote } from '@/lib/link' +import { cn } from '@/lib/utils' import logger from '@/lib/logger' import { NostrEvent, Event as NostrEventType } from 'nostr-tools' import { kinds } from 'nostr-tools' @@ -18,6 +19,7 @@ import ThreadCard from './ThreadCard' import CreateThreadDialog from './CreateThreadDialog' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { extractGroupInfo } from '@/lib/discussion-topics' +import type { TPageRef } from '@/types' // Simple event map type type EventMapEntry = { @@ -332,7 +334,10 @@ function DiscussionsPageTitlebar() { ) } -const DiscussionsPage = forwardRef((_, ref) => { +const DiscussionsPage = forwardRef(function DiscussionsPage( + { embedded = false }, + ref +) { const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { pubkey } = useNostr() @@ -775,7 +780,10 @@ const DiscussionsPage = forwardRef((_, ref) => { } const handleRestore = (e: CustomEvent<{ page: string, discussionsState?: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } }>) => { - if (e.detail.page === 'discussions' && e.detail.discussionsState) { + if ( + e.detail.discussionsState && + (e.detail.page === 'discussions' || e.detail.page === 'spells') + ) { setSelectedTopic(e.detail.discussionsState.selectedTopic) setTimeSpan(e.detail.discussionsState.timeSpan) } @@ -1044,14 +1052,14 @@ const DiscussionsPage = forwardRef((_, ref) => { navigateToNote(toNote(threadId)) } - return ( - } - displayScrollToTopButton - > -
+ const mainContent = ( + <> +
- ) - } -) diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 3f7aeba2..db35ce79 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -20,7 +20,6 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' -import FeedButton from './FeedButton' import { KeyboardShortcutsHelpButton } from '@/components/KeyboardShortcutsHelp' import ExploreButton from '@/components/Titlebar/ExploreButton' import AccountButton from '@/components/Titlebar/AccountButton' @@ -151,7 +150,6 @@ function NoteListPageTitlebar({
-
{isSmallScreen && (
diff --git a/src/pages/primary/NotificationListPage/index.tsx b/src/pages/primary/NotificationListPage/index.tsx deleted file mode 100644 index aab3579f..00000000 --- a/src/pages/primary/NotificationListPage/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import HideUntrustedContentButton from '@/components/HideUntrustedContentButton' -import NotificationList from '@/components/NotificationList' -import { RefreshButton } from '@/components/RefreshButton' -import Tabs from '@/components/Tabs' -import { usePrimaryPage } from '@/PageManager' -import { TNotificationType } from '@/types' -import { isTouchDevice } from '@/lib/utils' -import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' -import { Bell } from 'lucide-react' -import { forwardRef, useEffect, useRef, useState, useMemo } from 'react' -import { useTranslation } from 'react-i18next' - -const NotificationListPage = forwardRef((_, ref) => { - const { t } = useTranslation() - const { current } = usePrimaryPage() - const firstRenderRef = useRef(true) - const notificationListRef = useRef<{ refresh: () => void }>(null) - const [notificationType, setNotificationType] = useState('all') - const supportTouch = useMemo(() => isTouchDevice(), []) - - useEffect(() => { - if (current === 'notifications' && !firstRenderRef.current) { - notificationListRef.current?.refresh() - } - firstRenderRef.current = false - }, [current]) - - useEffect(() => { - const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => { - if (e.detail.page === 'notifications' && e.detail.tab) { - setNotificationType(e.detail.tab as TNotificationType) - } - } - window.addEventListener('restorePageTab', handleRestore as EventListener) - return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) - }, []) - - return ( - } - subHeader={ - { - setNotificationType(tab as TNotificationType) - window.dispatchEvent(new CustomEvent('pageTabChanged', { - detail: { page: 'notifications', tab } - })) - }} - options={!supportTouch ? notificationListRef.current?.refresh()} /> : null} - /> - } - displayScrollToTopButton - > -
- -
-
- ) -}) -NotificationListPage.displayName = 'NotificationListPage' -export default NotificationListPage - -function NotificationListPageTitlebar() { - const { t } = useTranslation() - - return ( -
-
- -
{t('Notifications')}
-
- -
- ) -} diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index c96f130f..f434443a 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -1,4 +1,8 @@ +import HideUntrustedContentButton from '@/components/HideUntrustedContentButton' import NoteList from '@/components/NoteList' +import NotificationList from '@/components/NotificationList' +import { RefreshButton } from '@/components/RefreshButton' +import Tabs from '@/components/Tabs' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { @@ -45,13 +49,29 @@ import { spellIsCount } from '@/services/spell.service' import { TFeedSubRequest } from '@/types' -import { Check, ChevronDown, Copy, FileText, MoreVertical, Pencil, Plus, Star, Trash2, Wand2 } from 'lucide-react' +import { + Bell, + Check, + ChevronDown, + Copy, + FileText, + MessageSquare, + MoreVertical, + Pencil, + Plus, + Star, + Trash2, + Wand2 +} from 'lucide-react' import type { Event } from 'nostr-tools' import { verifyEvent } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' import type { TPageRef } from '@/types' +import type { TNotificationType } from '@/types' +import { isTouchDevice } from '@/lib/utils' +import DiscussionsPage from '@/pages/primary/DiscussionsPage' /** Primary + optional subtitle (npub and/or short id). When grouped under an author header, omit npub. */ function spellPickerPrimaryAndSecondary( @@ -146,12 +166,30 @@ function SpellSheetOptionRow({ ) } -const SpellsPage = forwardRef(function SpellsPage(_, ref) { +const FAUX_SPELL_NAMES = ['notifications', 'discussions'] as const +type FauxSpellName = (typeof FAUX_SPELL_NAMES)[number] + +function isFauxSpellName(s: string): s is FauxSpellName { + return FAUX_SPELL_NAMES.includes(s as FauxSpellName) +} + +const SpellsPage = forwardRef(function SpellsPage({ spell: spellProp }: { spell?: string }, ref) { const { t } = useTranslation() const { pubkey, relayList, attemptDelete } = useNostr() const [spells, setSpells] = useState([]) const [favoriteIds, setFavoriteIds] = useState>(new Set()) const [selectedSpell, setSelectedSpell] = useState(null) + const [selectedFauxSpell, setSelectedFauxSpell] = useState(null) + const [notificationType, setNotificationType] = useState('all') + const notificationListRef = useRef<{ refresh: () => void }>(null) + const supportTouch = useMemo(() => isTouchDevice(), []) + + useEffect(() => { + if (spellProp && isFauxSpellName(spellProp)) { + setSelectedFauxSpell(spellProp) + setSelectedSpell(null) + } + }, [spellProp]) const [createOpen, setCreateOpen] = useState(false) const [spellToEdit, setSpellToEdit] = useState(null) const [spellToClone, setSpellToClone] = useState(null) @@ -538,6 +576,19 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) { const pickSpell = useCallback((spell: Event | null) => { setSelectedSpell(spell) + setSelectedFauxSpell(null) + setSpellPickerOpen(false) + }, []) + + const clearSpellSelection = useCallback(() => { + setSelectedSpell(null) + setSelectedFauxSpell(null) + setSpellPickerOpen(false) + }, []) + + const pickFauxSpell = useCallback((name: FauxSpellName | null) => { + setSelectedFauxSpell(name) + setSelectedSpell(null) setSpellPickerOpen(false) }, []) @@ -573,15 +624,28 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) { @@ -600,19 +664,57 @@ const SpellsPage = forwardRef(function SpellsPage(_, ref) { role="listbox" aria-label={t('Select a spell…')} > + {pubkey && ( + + )} +