Browse Source

refactor to a spells-based client

imwald
Silberengel 1 month ago
parent
commit
96a2ece8e1
  1. 140
      src/PageManager.tsx
  2. 4
      src/components/BottomNavigationBar/DiscussionsButton.tsx
  3. 6
      src/components/BottomNavigationBar/NotificationsButton.tsx
  4. 2
      src/components/BottomNavigationBar/index.tsx
  5. 33
      src/components/FeedSwitcher/index.tsx
  6. 174
      src/components/Note/EventViewer.tsx
  7. 28
      src/components/Note/UnknownNote.tsx
  8. 7
      src/components/NoteOptions/useMenuActions.tsx
  9. 8
      src/components/NotificationList/index.tsx
  10. 42
      src/components/PostEditor/PostContent.tsx
  11. 58
      src/components/Profile/ProfileArticles.tsx
  12. 964
      src/components/Profile/ProfileBookmarksAndHashtags.tsx
  13. 195
      src/components/Profile/ProfileFeedWithPins.tsx
  14. 315
      src/components/Profile/ProfileInteractions.tsx
  15. 57
      src/components/Profile/ProfileMedia.tsx
  16. 188
      src/components/Profile/ProfileNotes.tsx
  17. 391
      src/components/Profile/index.tsx
  18. 2
      src/components/ProfileOptions/index.tsx
  19. 4
      src/components/Sidebar/DiscussionsButton.tsx
  20. 13
      src/components/Sidebar/NotificationButton.tsx
  21. 27
      src/components/Sidebar/ProfileButton.tsx
  22. 11
      src/components/Sidebar/SpellsButton.tsx
  23. 10
      src/components/Sidebar/index.tsx
  24. 22
      src/constants.ts
  25. 194
      src/hooks/useProfileNotesTimeline.tsx
  26. 181
      src/hooks/useProfilePins.tsx
  27. 45
      src/pages/primary/DiscussionsPage/index.tsx
  28. 107
      src/pages/primary/NoteListPage/FeedButton.tsx
  29. 2
      src/pages/primary/NoteListPage/index.tsx
  30. 87
      src/pages/primary/NotificationListPage/index.tsx
  31. 149
      src/pages/primary/SpellsPage/index.tsx
  32. 12
      src/providers/KindFilterProvider.tsx
  33. 2
      src/routes.tsx

140
src/PageManager.tsx

@ -50,11 +50,9 @@ import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog' @@ -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 = { @@ -88,12 +86,10 @@ type TStackItem = {
const PRIMARY_PAGE_REF_MAP = {
home: createRef<TPageRef>(),
explore: createRef<TPageRef>(),
notifications: createRef<TPageRef>(),
me: createRef<TPageRef>(),
profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(),
search: createRef<TPageRef>(),
discussions: createRef<TPageRef>(),
spells: createRef<TPageRef>()
}
@ -102,12 +98,10 @@ const PRIMARY_PAGE_REF_MAP = { @@ -102,12 +98,10 @@ const PRIMARY_PAGE_REF_MAP = {
const getPrimaryPageMap = () => ({
home: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.home} />,
explore: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.explore} />,
notifications: <NotificationListPage ref={PRIMARY_PAGE_REF_MAP.notifications} />,
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />,
profile: <ProfilePage ref={PRIMARY_PAGE_REF_MAP.profile} />,
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
discussions: <DiscussionsPage ref={PRIMARY_PAGE_REF_MAP.discussions} />,
spells: (
<Suspense
fallback={
@ -124,6 +118,36 @@ const getPrimaryPageMap = () => ({ @@ -124,6 +118,36 @@ const getPrimaryPageMap = () => ({
// Type for primary page names - use the return type of getPrimaryPageMap
export type TPrimaryPageName = keyof ReturnType<typeof getPrimaryPageMap>
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<TPrimaryPageContext | undefined>(undefined)
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
@ -178,7 +202,7 @@ export function useNoteDrawer() { @@ -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): @@ -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() { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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,8 +821,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -797,8 +821,9 @@ 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') {
@ -807,28 +832,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -807,28 +832,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// 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 }) { @@ -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,12 +940,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -919,12 +940,12 @@ 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
@ -932,6 +953,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -932,6 +953,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// 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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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

4
src/components/BottomNavigationBar/DiscussionsButton.tsx

@ -7,8 +7,8 @@ export default function DiscussionsButton() { @@ -7,8 +7,8 @@ export default function DiscussionsButton() {
return (
<BottomNavigationBarItem
active={current === 'discussions' && display}
onClick={() => navigate('discussions')}
active={current === 'spells' && display}
onClick={() => navigate('spells', { spell: 'discussions' })}
>
<MessageCircle />
</BottomNavigationBarItem>

6
src/components/BottomNavigationBar/NotificationsButton.tsx

@ -4,13 +4,13 @@ import { Bell } from 'lucide-react' @@ -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 (
<BottomNavigationBarItem
active={current === 'notifications' && display}
onClick={() => checkLogin(() => navigate('notifications'))}
active={false}
onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))}
>
<Bell />
</BottomNavigationBarItem>

2
src/components/BottomNavigationBar/index.tsx

@ -1,7 +1,7 @@ @@ -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'

33
src/components/FeedSwitcher/index.tsx

@ -19,58 +19,59 @@ export default function FeedSwitcher({ close }: { close?: () => void }) { @@ -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 (
<div className="space-y-2">
{pubkey && (
{visibleRelays.length > 0 && (
<FeedSwitcherItem
isActive={feedInfo.feedType === 'following'}
isActive={feedInfo.feedType === 'all-favorites'}
onClick={() => {
if (!pubkey) return
switchFeed('following', { pubkey })
logger.debug('FeedSwitcher: Switching to all-favorites')
switchFeed('all-favorites')
close?.()
}}
>
<div className="flex gap-2 items-center">
<div className="flex justify-center items-center w-6 h-6 shrink-0">
<UsersRound className="size-4" />
<Server className="size-4" />
</div>
<div>{t('Following')}</div>
<div>{t('All favorite relays')}</div>
</div>
</FeedSwitcherItem>
)}
{pubkey && (
<FeedSwitcherItem
isActive={feedInfo.feedType === 'bookmarks'}
isActive={feedInfo.feedType === 'following'}
onClick={() => {
if (!pubkey) return
switchFeed('bookmarks', { pubkey })
switchFeed('following', { pubkey })
close?.()
}}
>
<div className="flex gap-2 items-center">
<div className="flex justify-center items-center w-6 h-6 shrink-0">
<BookmarkIcon className="size-4" />
<UsersRound className="size-4" />
</div>
<div>{t('Bookmarks')}</div>
<div>{t('Following')}</div>
</div>
</FeedSwitcherItem>
)}
{visibleRelays.length > 0 && (
{pubkey && (
<FeedSwitcherItem
isActive={feedInfo.feedType === 'all-favorites'}
isActive={feedInfo.feedType === 'bookmarks'}
onClick={() => {
logger.debug('FeedSwitcher: Switching to all-favorites')
switchFeed('all-favorites')
if (!pubkey) return
switchFeed('bookmarks', { pubkey })
close?.()
}}
>
<div className="flex gap-2 items-center">
<div className="flex justify-center items-center w-6 h-6 shrink-0">
<Server className="size-4" />
<BookmarkIcon className="size-4" />
</div>
<div>{t('All favorite relays')}</div>
<div>{t('Bookmarks')}</div>
</div>
</FeedSwitcherItem>
)}

174
src/components/Note/EventViewer.tsx

@ -0,0 +1,174 @@ @@ -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<Set<string>>(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 <span className="text-muted-foreground">null</span>
}
if (value === undefined) {
return <span className="text-muted-foreground">undefined</span>
}
if (typeof value === 'string') {
return <span className="text-green-600 dark:text-green-400">"{value}"</span>
}
if (typeof value === 'number' || typeof value === 'boolean') {
return <span className="text-blue-600 dark:text-blue-400">{String(value)}</span>
}
if (Array.isArray(value)) {
const isExpanded = expanded.has(key)
return (
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}>
<Collapsible open={isExpanded} onOpenChange={() => toggle(key)}>
<CollapsibleTrigger className="flex items-center gap-1 text-sm hover:text-foreground">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span className="text-muted-foreground">Array</span>
<span className="text-xs text-muted-foreground">({value.length})</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 ml-4">
{value.map((item, idx) => (
<div key={idx} className="mb-1">
<span className="text-muted-foreground text-xs">[{idx}]</span>{' '}
{renderValue(item, `${key}[${idx}]`, depth + 1)}
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
)
}
if (typeof value === 'object') {
const isExpanded = expanded.has(key)
const entries = Object.entries(value)
return (
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}>
<Collapsible open={isExpanded} onOpenChange={() => toggle(key)}>
<CollapsibleTrigger className="flex items-center gap-1 text-sm hover:text-foreground">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span className="text-muted-foreground">Object</span>
<span className="text-xs text-muted-foreground">({entries.length} keys)</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 ml-4">
{entries.map(([k, v]) => (
<div key={k} className="mb-1">
<span className="text-purple-600 dark:text-purple-400 font-medium">"{k}"</span>:{' '}
{renderValue(v, `${key}.${k}`, depth + 1)}
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
)
}
return <span className="text-muted-foreground">{String(value)}</span>
}
const createdAtFormatted = dayjs(event.created_at * 1000).format('LLL')
return (
<div className={cn('border rounded-lg p-4 bg-muted/30', className)}>
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold">Event (kind {event.kind})</div>
<Button variant="ghost" size="sm" onClick={handleCopyJson} className="h-7">
{copiedJson ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</div>
<div className="text-sm space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-purple-600 dark:text-purple-400 font-medium shrink-0">nevent</span>
<code className="truncate text-green-600 dark:text-green-400 text-xs">{nevent}</code>
<Button variant="ghost" size="sm" onClick={handleCopyNevent} className="h-6 w-6 p-0 shrink-0">
{copiedNevent ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
<div className="flex items-center gap-2">
<span className="text-purple-600 dark:text-purple-400 font-medium shrink-0">pubkey</span>
<div className="flex items-center gap-1.5">
<UserAvatar userId={event.pubkey} size="xSmall" />
<Username userId={event.pubkey} className="font-normal" skeletonClassName="h-4" />
</div>
</div>
<div>
<span className="text-purple-600 dark:text-purple-400 font-medium">kind</span>{' '}
{renderValue(event.kind, 'kind')}
</div>
<div>
<span className="text-purple-600 dark:text-purple-400 font-medium">created_at</span>{' '}
<span className="text-muted-foreground">{createdAtFormatted}</span>
</div>
<div className="font-mono">
<span className="text-purple-600 dark:text-purple-400 font-medium">tags</span>{' '}
{renderValue(event.tags, 'tags')}
</div>
<div className="font-mono">
<span className="text-purple-600 dark:text-purple-400 font-medium">content</span>{' '}
{renderValue(event.content, 'content')}
</div>
</div>
</div>
)
}

28
src/components/Note/UnknownNote.tsx

@ -5,6 +5,7 @@ import ClientSelect from '../ClientSelect' @@ -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 @@ -21,21 +22,24 @@ export default function UnknownNote({ event, className }: { event: Event; classN
return (
<div
className={cn(
'flex flex-col gap-2 items-center text-muted-foreground font-medium my-4',
'flex flex-col gap-3 my-4',
className
)}
>
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div>
{isBookstrEvent && (
<div className="text-xs text-muted-foreground space-x-2">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {formatBookName(bookMetadata.book)}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>}
{bookMetadata.verse && <span>Verse: {bookMetadata.verse}</span>}
{bookMetadata.version && <span>Version: {bookMetadata.version.toUpperCase()}</span>}
</div>
)}
<ClientSelect event={event} />
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium">
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div>
{isBookstrEvent && (
<div className="text-xs text-muted-foreground space-x-2">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {formatBookName(bookMetadata.book)}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>}
{bookMetadata.verse && <span>Verse: {bookMetadata.verse}</span>}
{bookMetadata.version && <span>Version: {bookMetadata.version.toUpperCase()}</span>}
</div>
)}
<ClientSelect event={event} />
</div>
<EventViewer event={event} />
</div>
)
}

7
src/components/NoteOptions/useMenuActions.tsx

@ -612,9 +612,10 @@ export function useMenuActions({ @@ -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)

8
src/components/NotificationList/index.tsx

@ -37,8 +37,8 @@ const NotificationList = forwardRef( @@ -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( @@ -142,8 +142,6 @@ const NotificationList = forwardRef(
)
useEffect(() => {
if (current !== 'notifications') return
if (!pubkey) {
setUntil(undefined)
return
@ -234,7 +232,7 @@ const NotificationList = forwardRef( @@ -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

42
src/components/PostEditor/PostContent.tsx

@ -41,12 +41,30 @@ import logger from '@/lib/logger' @@ -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({ @@ -116,6 +134,7 @@ export default function PostContent({
relays: []
})
const [minPow, setMinPow] = useState(0)
const [createThreadOpen, setCreateThreadOpen] = useState(false)
const [mediaNoteKind, setMediaNoteKind] = useState<number | null>(null)
const [mediaImetaTags, setMediaImetaTags] = useState<string[][]>([])
const [mediaUrl, setMediaUrl] = useState<string>('')
@ -2031,6 +2050,14 @@ export default function PostContent({ @@ -2031,6 +2050,14 @@ export default function PostContent({
>
<ListTodo className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={t('Create Thread')}
onClick={() => checkLogin(() => setCreateThreadOpen(true))}
>
<MessagesSquare className="h-4 w-4" />
</Button>
{/* Article dropdown - only show if has private relays for publication content */}
{(hasPrivateRelaysAvailable || !isPublicationContent) && (
<DropdownMenu>
@ -2403,6 +2430,19 @@ export default function PostContent({ @@ -2403,6 +2430,19 @@ export default function PostContent({
</DialogContent>
</Dialog>
</NeventPickerProvider>
{createThreadOpen && (
<CreateThreadDialog
topic="general"
availableRelays={[]}
relaySets={[]}
onClose={() => setCreateThreadOpen(false)}
onThreadCreated={() => {
discussionFeedCache.clearDiscussionsListCache()
setCreateThreadOpen(false)
close()
}}
/>
)}
</div>
)
}

58
src/components/Profile/ProfileArticles.tsx

@ -1,58 +0,0 @@ @@ -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 (
<ProfileTimeline
ref={ref}
pubkey={pubkey}
topSpace={topSpace}
searchQuery={searchQuery}
kindFilter={kindFilter}
onEventsChange={onEventsChange}
kinds={ARTICLE_KINDS}
cacheKey={cacheKey}
getKindLabel={getKindLabel}
refreshLabel="Refreshing articles..."
emptyLabel="No articles found"
emptySearchLabel="No articles match your search"
/>
)
}
)
ProfileArticles.displayName = 'ProfileArticles'
export default ProfileArticles

964
src/components/Profile/ProfileBookmarksAndHashtags.tsx

@ -1,964 +0,0 @@ @@ -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<string, BookmarksCacheEntry>()
const hashtagsCache = new Map<string, HashtagsCacheEntry>()
const pinsCache = new Map<string, PinsCacheEntry>()
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<Event[]>([])
const [hashtagEvents, setHashtagEvents] = useState<Event[]>([])
const [pinEvents, setPinEvents] = useState<Event[]>([])
const [loadingBookmarks, setLoadingBookmarks] = useState(true)
const [loadingHashtags, setLoadingHashtags] = useState(true)
const [loadingPins, setLoadingPins] = useState(true)
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
const [interestListEvent, setInterestListEvent] = useState<Event | null>(null)
const [pinListEvent, setPinListEvent] = useState<Event | null>(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<Event[]>[] = []
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<Event[]>[] = []
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 (
<div className="space-y-2">
{isRetrying && retryCount > 0 && (
<div className="text-center py-2 text-sm text-muted-foreground">
Retrying... ({retryCount}/{maxRetries})
</div>
)}
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
// 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 (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing pins...
</div>
)
}
if (loadingPins) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (pinEvents.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
{t('No pins found')}
</div>
)
}
if (filteredPinEvents.length === 0 && searchQuery.trim()) {
return (
<div className="text-center py-8 text-muted-foreground">
No pins match your search
</div>
)
}
return (
<div className="min-h-screen">
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredPinEvents.length} of {pinEvents.length} pins
</div>
)}
<div className="space-y-2">
{filteredPinEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
/>
))}
</div>
</div>
)
}
if (initialTab === 'bookmarks') {
if (isRefreshing) {
return (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing bookmarks...
</div>
)
}
if (loadingBookmarks) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (bookmarkEvents.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
{t('No bookmarks found')}
</div>
)
}
if (filteredBookmarkEvents.length === 0 && searchQuery.trim()) {
return (
<div className="text-center py-8 text-muted-foreground">
No bookmarks match your search
</div>
)
}
return (
<div className="min-h-screen">
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredBookmarkEvents.length} of {bookmarkEvents.length} bookmarks
</div>
)}
<div className="space-y-2">
{filteredBookmarkEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
/>
))}
</div>
</div>
)
}
if (initialTab === 'hashtags') {
if (isRefreshing) {
return (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing interests...
</div>
)
}
if (loadingHashtags) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (hashtagEvents.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
{t('No interest-related content found')}
</div>
)
}
if (filteredHashtagEvents.length === 0 && searchQuery.trim()) {
return (
<div className="text-center py-8 text-muted-foreground">
No interests match your search
</div>
)
}
return (
<div className="min-h-screen">
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredHashtagEvents.length} of {hashtagEvents.length} interests
</div>
)}
<div className="space-y-2">
{filteredHashtagEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
/>
))}
</div>
</div>
)
}
return null
}
return renderContent()
})
ProfileBookmarksAndHashtags.displayName = 'ProfileBookmarksAndHashtags'
export default ProfileBookmarksAndHashtags

195
src/components/Profile/ProfileFeedWithPins.tsx

@ -0,0 +1,195 @@ @@ -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<HTMLDivElement>(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 (
<div className="mt-4 space-y-2 px-1">
<div className="flex flex-wrap items-center gap-2 px-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" />
</div>
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
</div>
)
}
if (!mergedDisplay.length && !loadingPins && !loadingTimeline) {
return (
<div className="mt-4 px-2">
<div className="flex flex-wrap items-center gap-2 mb-4">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" />
</div>
<div className="flex justify-center py-8 text-sm text-muted-foreground">
{searchQuery.trim() ? t('No posts match your search') : t('No posts found')}
</div>
</div>
)
}
return (
<div className="mt-4">
<div className="flex flex-wrap items-center gap-2 px-2 mb-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" />
</div>
{isRefreshing && (
<div className="px-4 py-2 text-center text-sm text-green-500">🔄 {t('Refreshing posts...')}</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{t('Showing {{filtered}} of {{total}} items', {
filtered: displayedEvents.length,
total: mergedDisplay.length
})}
</div>
)}
<div className="space-y-2">
{displayedEvents.map((event, index) => (
<div key={event.id}>
{index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && (
<div className="text-xs text-muted-foreground px-2 py-1 border-t border-border/60 mt-2 pt-2">
{t('Posts')}
</div>
)}
<NoteCard className="w-full" event={event} filterMutedNotes={false} />
</div>
))}
</div>
{displayedEvents.length < mergedDisplay.length && (
<div ref={bottomRef} className="flex h-10 items-center justify-center">
<div className="text-sm text-muted-foreground">{t('Loading more...')}</div>
</div>
)}
</div>
)
})
ProfileFeedWithPins.displayName = 'ProfileFeedWithPins'
export default ProfileFeedWithPins

315
src/components/Profile/ProfileInteractions.tsx

@ -1,315 +0,0 @@ @@ -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<string, InteractionsCacheEntry>()
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<Event[]>([])
const [isLoading, setIsLoading] = useState(true)
const [refreshToken, setRefreshToken] = useState(0)
const bottomRef = useRef<HTMLDivElement>(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<string>()
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 (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No interactions to show</div>
</div>
)
}
if (isLoading && events.length === 0) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (!filteredEvents.length && !isLoading) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">
{searchQuery.trim() ? 'No interactions match your search' : 'No interactions found'}
</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing interactions...</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
Showing {displayedEvents.length} of {filteredEvents.length} interactions
</div>
)}
<div className="space-y-2">
{displayedEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
{displayedEvents.length < filteredEvents.length && (
<div ref={bottomRef} className="h-10 flex items-center justify-center">
<div className="text-sm text-muted-foreground">Loading more...</div>
</div>
)}
</div>
)
}
)
ProfileInteractions.displayName = 'ProfileInteractions'
export default ProfileInteractions

57
src/components/Profile/ProfileMedia.tsx

@ -1,57 +0,0 @@ @@ -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 (
<ProfileTimeline
ref={ref}
pubkey={pubkey}
topSpace={topSpace}
searchQuery={searchQuery}
kindFilter={kindFilter}
onEventsChange={onEventsChange}
kinds={MEDIA_KIND_LIST}
cacheKey={cacheKey}
getKindLabel={getKindLabel}
refreshLabel="Refreshing media..."
emptyLabel="No media found"
emptySearchLabel="No media match your search"
/>
)
}
)
ProfileMedia.displayName = 'ProfileMedia'
export default ProfileMedia

188
src/components/Profile/ProfileNotes.tsx

@ -1,188 +0,0 @@ @@ -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<HTMLDivElement>(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 (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No profile selected</div>
</div>
)
}
if (isLoading && timelineEvents.length === 0) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (!filteredEvents.length && !isLoading) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">
{searchQuery.trim() ? 'No notes match your search' : 'No notes found'}
</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing notes...</div>
)}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">
Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)}
</div>
)}
<div className="space-y-2">
{displayedEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
{displayedEvents.length < filteredEvents.length && (
<div ref={bottomRef} className="h-10 flex items-center justify-center">
<div className="text-sm text-muted-foreground">Loading more...</div>
</div>
)}
</div>
)
}
)
ProfileNotes.displayName = 'ProfileNotes'
export default ProfileNotes

391
src/components/Profile/index.tsx

@ -8,28 +8,15 @@ import ProfileBanner from '@/components/ProfileBanner' @@ -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 { @@ -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' @@ -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 }) { @@ -180,7 +160,7 @@ export default function Profile({ id }: { id?: string }) {
const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr()
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
const [profileEvent, setProfileEvent] = useState<Event | undefined>(undefined)
const [profileEvent, setProfileEvent] = useState<NostrEvent | undefined>(undefined)
const [openZapDialog, setOpenZapDialog] = useState(false)
const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(null)
const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null)
@ -266,97 +246,6 @@ export default function Profile({ id }: { id?: string }) { @@ -266,97 +246,6 @@ export default function Profile({ id }: { id?: string }) {
fetchProfileEventData()
}, [profile?.pubkey])
const [activeTab, setActiveTab] = useState<ProfileTabValue>('posts')
const [searchQuery, setSearchQuery] = useState('')
const [articleKindFilter, setArticleKindFilter] = useState<string>('all')
const [postKindFilter, setPostKindFilter] = useState<string>('all')
const [mediaKindFilter, setMediaKindFilter] = useState<string>('all')
const [notesKindFilter, setNotesKindFilter] = useState<string>('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<Event[]>([])
const [postEvents, setPostEvents] = useState<Event[]>([])
const [mediaEvents, setMediaEvents] = useState<Event[]>([])
const [_interactionEvents, setInteractionEvents] = useState<Event[]>([])
const [notesEvents, setNotesEvents] = useState<Event[]>([])
const isFollowingYou = useMemo(() => {
// This will be handled by the FollowedBy component
@ -424,71 +313,6 @@ export default function Profile({ id }: { id?: string }) { @@ -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 }) { @@ -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 }) { @@ -750,198 +562,7 @@ export default function Profile({ id }: { id?: string }) {
</div>
</div>
</div>
<div>
<div className="space-y-2">
<Tabs
value={activeTab}
tabs={tabs}
onTabChange={(tab) => {
setActiveTab(tab as ProfileTabValue)
// Dispatch tab change event for PageManager
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'profile', tab: tab }
}))
}}
threshold={800}
/>
<div className="flex items-center gap-2 pr-2 px-1">
<ProfileSearchBar
onSearch={activeTab === 'articles' ? handleArticleSearch : setSearchQuery}
placeholder={`Search ${
activeTab === 'posts' ? 'posts' : activeTab === 'media' ? 'media' : activeTab === 'notes' ? 'notes' : activeTab
}...`}
className="w-64"
/>
{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 (
<Select value={postKindFilter} onValueChange={setPostKindFilter}>
<SelectTrigger className="w-48">
<FileText className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter posts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Posts ({allCount})</SelectItem>
<SelectItem value={String(kinds.ShortTextNote)}>Notes ({noteCount})</SelectItem>
<SelectItem value={String(kinds.Repost)}>Reposts ({repostCount})</SelectItem>
<SelectItem value={String(ExtendedKind.COMMENT)}>Comments ({commentCount})</SelectItem>
<SelectItem value={String(ExtendedKind.DISCUSSION)}>Discussions ({discussionCount})</SelectItem>
<SelectItem value={String(ExtendedKind.POLL)}>Polls ({pollCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CALENDAR_EVENT_TIME)}>Calendar Events ({calendarEventCount})</SelectItem>
<SelectItem value={String(ExtendedKind.ZAP_RECEIPT)}>Superzaps ({superzapCount})</SelectItem>
</SelectContent>
</Select>
)
})()}
{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 (
<Select value={articleKindFilter} onValueChange={setArticleKindFilter}>
<SelectTrigger className="w-48">
<FileText className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter articles" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types ({allCount})</SelectItem>
<SelectItem value={String(kinds.LongFormArticle)}>Long Form Articles ({longFormCount})</SelectItem>
<SelectItem value={String(ExtendedKind.WIKI_ARTICLE_MARKDOWN)}>Wiki (Markdown) ({wikiMarkdownCount})</SelectItem>
<SelectItem value={String(ExtendedKind.WIKI_ARTICLE)}>Wiki (AsciiDoc) ({wikiAsciiDocCount})</SelectItem>
<SelectItem value={String(ExtendedKind.PUBLICATION)}>Publications ({publicationCount})</SelectItem>
<SelectItem value={String(kinds.Highlights)}>Highlights ({highlightsCount})</SelectItem>
</SelectContent>
</Select>
)
})()}
{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 (
<Select value={mediaKindFilter} onValueChange={setMediaKindFilter}>
<SelectTrigger className="w-52">
<Film className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter media" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Media ({allCount})</SelectItem>
<SelectItem value={String(ExtendedKind.PICTURE)}>Photos ({pictureCount})</SelectItem>
<SelectItem value={String(ExtendedKind.VIDEO)}>Videos ({videoCount})</SelectItem>
<SelectItem value={String(ExtendedKind.SHORT_VIDEO)}>Short Videos ({shortVideoCount})</SelectItem>
<SelectItem value={String(ExtendedKind.VOICE)}>Voice Posts ({voiceCount})</SelectItem>
<SelectItem value={String(ExtendedKind.VOICE_COMMENT)}>Voice Comments ({voiceCommentCount})</SelectItem>
</SelectContent>
</Select>
)
})()}
{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 (
<Select value={notesKindFilter} onValueChange={setNotesKindFilter}>
<SelectTrigger className="w-52">
<FileText className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter notes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Notes ({allCount})</SelectItem>
<SelectItem value={String(ExtendedKind.PUBLICATION_CONTENT)}>Notes ({publicationContentCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CITATION_INTERNAL)}>Internal Citations ({internalCitationCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CITATION_EXTERNAL)}>External Citations ({externalCitationCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CITATION_HARDCOPY)}>Hardcopy Citations ({hardcopyCitationCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CITATION_PROMPT)}>Prompt Citations ({promptCitationCount})</SelectItem>
</SelectContent>
</Select>
)
})()}
<RetroRefreshButton onClick={handleRefresh} size="sm" className="flex-shrink-0" />
</div>
</div>
{activeTab === 'posts' && (
<ProfileFeed
ref={profileFeedRef}
pubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
kindFilter={postKindFilter}
onEventsChange={setPostEvents}
/>
)}
{activeTab === 'articles' && (
<ProfileArticles
ref={profileArticlesRef}
pubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
kindFilter={articleKindFilter}
onEventsChange={setArticleEvents}
/>
)}
{activeTab === 'media' && (
<ProfileMedia
ref={profileMediaRef}
pubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
kindFilter={mediaKindFilter}
onEventsChange={setMediaEvents}
/>
)}
{(activeTab === 'pins' || activeTab === 'bookmarks' || activeTab === 'interests') && (
<ProfileBookmarksAndHashtags
ref={profileBookmarksRef}
pubkey={pubkey}
initialTab={activeTab === 'pins' ? 'pins' : activeTab === 'bookmarks' ? 'bookmarks' : 'hashtags'}
searchQuery={searchQuery}
/>
)}
{activeTab === 'notes' && (
<ProfileNotes
ref={profileNotesRef}
pubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
kindFilter={notesKindFilter}
onEventsChange={setNotesEvents}
/>
)}
{activeTab === 'you' && accountPubkey && (
<ProfileInteractions
ref={profileInteractionsRef}
accountPubkey={accountPubkey}
profilePubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
onEventsChange={setInteractionEvents}
/>
)}
</div>
<ProfileFeedWithPins pubkey={pubkey} />
{openPublicMessageTo && (
<PostEditor
open={!!openPublicMessageTo}

2
src/components/ProfileOptions/index.tsx

@ -22,7 +22,7 @@ import { Bell, BellOff, Copy, Ellipsis, MessageCircle, Send, Video, SatelliteDis @@ -22,7 +22,7 @@ import { Bell, BellOff, Copy, Ellipsis, MessageCircle, Send, Video, SatelliteDis
import { useMemo, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Event, kinds } from 'nostr-tools'
import { Event } from 'nostr-tools'
export default function ProfileOptions({
pubkey,

4
src/components/Sidebar/DiscussionsButton.tsx

@ -11,8 +11,8 @@ export default function DiscussionsButton() { @@ -11,8 +11,8 @@ export default function DiscussionsButton() {
return (
<SidebarItem
title={t('Discussions')}
onClick={() => navigate('discussions')}
active={display && current === 'discussions' && primaryViewType === null}
onClick={() => navigate('spells', { spell: 'discussions' })}
active={display && current === 'spells' && primaryViewType === null}
>
<MessageCircle strokeWidth={3} />
</SidebarItem>

13
src/components/Sidebar/NotificationButton.tsx

@ -1,18 +1,17 @@ @@ -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 (
<SidebarItem
title="Notifications"
onClick={() => checkLogin(() => navigate('notifications'))}
active={display && current === 'notifications' && primaryViewType === null}
title="notifications"
onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))}
active={false}
>
<Bell strokeWidth={3} />
</SidebarItem>

27
src/components/Sidebar/ProfileButton.tsx

@ -1,27 +0,0 @@ @@ -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 (
<SidebarItem
title="Profile"
onClick={() => checkLogin(() => navigate('profile'))}
active={isActive}
>
<UserRound strokeWidth={3} />
</SidebarItem>
)
}

11
src/components/Sidebar/SpellsButton.tsx

@ -1,14 +1,17 @@ @@ -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 (
<SidebarItem title="Spells" onClick={() => navigate('spells')} active={isActive}>
<SidebarItem
title="Spells"
onClick={() => navigate('spells')}
active={current === 'spells' && display && primaryViewType === null}
>
<Wand2 strokeWidth={3} />
</SidebarItem>
)

10
src/components/Sidebar/index.tsx

@ -6,13 +6,12 @@ import KeyboardShortcutsHelpSidebarButton from './KeyboardShortcutsHelpSidebarBu @@ -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() { @@ -36,11 +35,10 @@ export default function PrimaryPageSidebar() {
<HomeButton />
<RelaysButton />
<DiscussionsButton />
<NotificationsButton />
<NotificationButton />
<SearchButton />
<ProfileButton />
{showRssFeed && <RssButton />}
<SpellsButton />
{showRssFeed && <RssButton />}
<SettingsButton />
<PostButton />
</div>

22
src/constants.ts

@ -267,6 +267,28 @@ export const SUPPORTED_KINDS = [ @@ -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 =

194
src/hooks/useProfileNotesTimeline.tsx

@ -1,194 +0,0 @@ @@ -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<string, ProfileNotesTimelineCacheEntry>()
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<string, Event>()
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<Event[]>(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
}
}

181
src/hooks/useProfilePins.tsx

@ -0,0 +1,181 @@ @@ -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<string, PinsCacheEntry>()
function orderPinEvents(pinList: Event, eventsById: Map<string, Event>): Event[] {
const ordered: Event[] = []
const seen = new Set<string>()
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<Event[]>([])
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<Event[]>[] = []
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<string, Event>()
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 }
}

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

@ -5,6 +5,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -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' @@ -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() { @@ -332,7 +334,10 @@ function DiscussionsPageTitlebar() {
)
}
const DiscussionsPage = forwardRef((_, ref) => {
const DiscussionsPage = forwardRef<TPageRef, { embedded?: boolean }>(function DiscussionsPage(
{ embedded = false },
ref
) {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { pubkey } = useNostr()
@ -775,7 +780,10 @@ const DiscussionsPage = forwardRef((_, ref) => { @@ -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) => { @@ -1044,14 +1052,14 @@ const DiscussionsPage = forwardRef((_, ref) => {
navigateToNote(toNote(threadId))
}
return (
<PrimaryPageLayout
ref={ref}
pageName="discussions"
titlebar={<DiscussionsPageTitlebar />}
displayScrollToTopButton
>
<div className="min-w-0 pt-14 sm:pt-4 flex flex-col gap-4 p-4">
const mainContent = (
<>
<div
className={cn(
'min-w-0 flex flex-col gap-4 p-4',
embedded ? 'pt-2' : 'pt-14 sm:pt-4'
)}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<button
onClick={() => setShowCreateDialog(true)}
@ -1218,6 +1226,23 @@ const DiscussionsPage = forwardRef((_, ref) => { @@ -1218,6 +1226,23 @@ const DiscussionsPage = forwardRef((_, ref) => {
onThreadCreated={handleCreateThread}
/>
)}
</>
)
if (embedded) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-auto">{mainContent}</div>
)
}
return (
<PrimaryPageLayout
ref={ref}
pageName="spells"
titlebar={<DiscussionsPageTitlebar />}
displayScrollToTopButton
>
{mainContent}
</PrimaryPageLayout>
)
})

107
src/pages/primary/NoteListPage/FeedButton.tsx

@ -1,107 +0,0 @@ @@ -1,107 +0,0 @@
import FeedSwitcher from '@/components/FeedSwitcher'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BookmarkIcon, ChevronDown, Server, UsersRound } from 'lucide-react'
import { forwardRef, ButtonHTMLAttributes, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function FeedButton({ className }: { className?: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
if (isSmallScreen) {
return (
<>
<FeedSwitcherTrigger className={className} onClick={() => setOpen(true)} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[80vh]">
<DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose feed')}</DrawerTitle>
</DrawerHeader>
<div
className="overflow-y-auto overscroll-contain py-2 px-4"
style={{ touchAction: 'pan-y' }}
>
<FeedSwitcher close={() => setOpen(false)} />
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FeedSwitcherTrigger className={className} />
</PopoverTrigger>
<PopoverContent
sideOffset={0}
side="bottom"
className="w-96 p-4 max-h-[80vh] overflow-auto scrollbar-hide"
>
<FeedSwitcher close={() => setOpen(false)} />
</PopoverContent>
</Popover>
)
}
const FeedSwitcherTrigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
({ className, ...props }, ref) => {
const { t } = useTranslation()
const { feedInfo, relayUrls } = useFeed()
const { relaySets } = useFavoriteRelays()
const activeRelaySet = useMemo(() => {
return feedInfo.feedType === 'relays' && feedInfo.id
? relaySets.find((set) => set.id === feedInfo.id)
: undefined
}, [feedInfo, relaySets])
const title = useMemo(() => {
if (feedInfo.feedType === 'following') {
return t('Following')
}
if (feedInfo.feedType === 'bookmarks') {
return t('Bookmarks')
}
if (feedInfo.feedType === 'all-favorites') {
return t('All favorite relays')
}
if (relayUrls.length === 0) {
return t('Choose a relay')
}
if (feedInfo.feedType === 'relay') {
return simplifyUrl(feedInfo.id ?? '')
}
if (feedInfo.feedType === 'relays') {
return activeRelaySet?.name ?? activeRelaySet?.id
}
}, [feedInfo, activeRelaySet])
return (
<button
type="button"
className={cn('flex items-center gap-2 clickable px-3 h-full rounded-lg bg-transparent border-0 text-left', className)}
ref={ref}
{...props}
>
{feedInfo.feedType === 'following' ? (
<UsersRound />
) : feedInfo.feedType === 'bookmarks' ? (
<BookmarkIcon />
) : feedInfo.feedType === 'all-favorites' ? (
<Server />
) : (
<Server />
)}
<span className="text-lg font-semibold truncate">{title}</span>
<ChevronDown />
</button>
)
}
)

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

@ -20,7 +20,6 @@ import React, { @@ -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({ @@ -151,7 +150,6 @@ function NoteListPageTitlebar({
<div className="relative flex gap-1 items-center h-full justify-between">
<div className="flex gap-1 items-center">
<ExploreButton />
<FeedButton className="flex-1 max-w-fit w-0" />
</div>
{isSmallScreen && (
<div className="absolute left-1/2 transform -translate-x-1/2 z-10">

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

@ -1,87 +0,0 @@ @@ -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<TNotificationType>('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 (
<PrimaryPageLayout
ref={ref}
pageName="notifications"
titlebar={<NotificationListPageTitlebar />}
subHeader={
<Tabs
value={notificationType}
tabs={[
{ value: 'all', label: t('All') },
{ value: 'mentions', label: t('Mentions') },
{ value: 'reactions', label: t('Reactions') },
{ value: 'zaps', label: t('Zaps') }
]}
onTabChange={(tab) => {
setNotificationType(tab as TNotificationType)
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'notifications', tab }
}))
}}
options={!supportTouch ? <RefreshButton onClick={() => notificationListRef.current?.refresh()} /> : null}
/>
}
displayScrollToTopButton
>
<div className="min-w-0 pt-2">
<NotificationList
ref={notificationListRef}
notificationType={notificationType}
/>
</div>
</PrimaryPageLayout>
)
})
NotificationListPage.displayName = 'NotificationListPage'
export default NotificationListPage
function NotificationListPageTitlebar() {
const { t } = useTranslation()
return (
<div className="flex gap-2 items-center justify-between h-full pl-3">
<div className="flex items-center gap-2">
<Bell />
<div className="text-lg font-semibold">{t('Notifications')}</div>
</div>
<HideUntrustedContentButton type="notifications" size="titlebar-icon" />
</div>
)
}

149
src/pages/primary/SpellsPage/index.tsx

@ -1,4 +1,8 @@ @@ -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 { @@ -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({ @@ -146,12 +166,30 @@ function SpellSheetOptionRow({
)
}
const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage({ spell: spellProp }: { spell?: string }, ref) {
const { t } = useTranslation()
const { pubkey, relayList, attemptDelete } = useNostr()
const [spells, setSpells] = useState<Event[]>([])
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
const [selectedFauxSpell, setSelectedFauxSpell] = useState<FauxSpellName | null>(null)
const [notificationType, setNotificationType] = useState<TNotificationType>('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<Event | null>(null)
const [spellToClone, setSpellToClone] = useState<Event | null>(null)
@ -538,6 +576,19 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -538,6 +576,19 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage(_, ref) { @@ -573,15 +624,28 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<Button
type="button"
variant="outline"
disabled={spellsForSelect.length === 0}
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined}
title={
selectedFauxSpell === 'notifications'
? t('Notifications')
: selectedFauxSpell === 'discussions'
? t('Discussions')
: selectedSpell
? spellMenuLabel(selectedSpell)
: undefined
}
aria-haspopup="dialog"
aria-expanded={spellPickerOpen}
onClick={() => setSpellPickerOpen(true)}
>
<span className="truncate">
{selectedSpell ? spellMenuLabel(selectedSpell) : t('Select a spell…')}
{selectedFauxSpell === 'notifications'
? t('Notifications')
: selectedFauxSpell === 'discussions'
? t('Discussions')
: selectedSpell
? spellMenuLabel(selectedSpell)
: t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
@ -600,19 +664,57 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -600,19 +664,57 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
role="listbox"
aria-label={t('Select a spell…')}
>
{pubkey && (
<button
type="button"
role="option"
aria-selected={selectedFauxSpell === 'notifications'}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selectedFauxSpell === 'notifications' && 'bg-accent/50'
)}
onClick={() => pickFauxSpell(selectedFauxSpell === 'notifications' ? null : 'notifications')}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{selectedFauxSpell === 'notifications' ? <Check className="size-4" aria-hidden /> : null}
</span>
<Bell className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left font-medium">
{t('Notifications')}
</span>
</button>
)}
<button
type="button"
role="option"
aria-selected={!selectedSpell}
aria-selected={selectedFauxSpell === 'discussions'}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
!selectedSpell && 'bg-accent/50'
selectedFauxSpell === 'discussions' && 'bg-accent/50'
)}
onClick={() => pickSpell(null)}
onClick={() => pickFauxSpell(selectedFauxSpell === 'discussions' ? null : 'discussions')}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{!selectedSpell ? <Check className="size-4" aria-hidden /> : null}
{selectedFauxSpell === 'discussions' ? <Check className="size-4" aria-hidden /> : null}
</span>
<MessageSquare className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left font-medium">{t('Discussions')}</span>
</button>
<button
type="button"
role="option"
aria-selected={!selectedSpell && !selectedFauxSpell}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
!selectedSpell && !selectedFauxSpell && 'bg-accent/50'
)}
onClick={clearSpellSelection}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{!selectedSpell && !selectedFauxSpell ? <Check className="size-4" aria-hidden /> : null}
</span>
<span className="min-w-0 flex-1 truncate text-left font-normal text-muted-foreground">
{t('Select a spell…')}
@ -791,7 +893,34 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -791,7 +893,34 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
{/* Feed */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedSpell ? (
{selectedFauxSpell === 'notifications' ? (
<>
<div className="shrink-0 flex items-center justify-between gap-2 px-1 pb-2">
<Tabs
value={notificationType}
tabs={[
{ value: 'all', label: t('All') },
{ value: 'mentions', label: t('Mentions') },
{ value: 'reactions', label: t('Reactions') },
{ value: 'zaps', label: t('Zaps') }
]}
onTabChange={(tab) => setNotificationType(tab as TNotificationType)}
options={!supportTouch ? <RefreshButton onClick={() => notificationListRef.current?.refresh()} /> : null}
/>
<HideUntrustedContentButton type="notifications" size="titlebar-icon" />
</div>
<div className="min-h-0 min-w-0 flex-1">
<NotificationList
ref={notificationListRef}
notificationType={notificationType}
/>
</div>
</>
) : selectedFauxSpell === 'discussions' ? (
<div className="min-h-0 min-w-0 flex-1">
<DiscussionsPage embedded />
</div>
) : selectedSpell ? (
spellIsCount(selectedSpell) ? (
<div className="flex flex-col items-center justify-center gap-3 py-10 px-4">
{spellCount.error === 'login' ? (

12
src/providers/KindFilterProvider.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { createContext, useContext, useState, useCallback, useMemo } from 'react'
import storage from '@/services/local-storage.service'
import { SUPPORTED_KINDS, ExtendedKind } from '@/constants'
import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants'
import { kinds } from 'nostr-tools'
const KIND_1 = kinds.ShortTextNote
@ -42,15 +42,7 @@ export const useKindFilter = () => { @@ -42,15 +42,7 @@ export const useKindFilter = () => {
}
export function KindFilterProvider({ children }: { children: React.ReactNode }) {
// Ensure we always have a default value - show all supported kinds except reposts, publications, publication content, and NIP-89 handler kinds (not shown in feed filter UI)
const defaultShowKinds = SUPPORTED_KINDS.filter(
(kind) =>
kind !== kinds.Repost &&
kind !== ExtendedKind.PUBLICATION &&
kind !== ExtendedKind.PUBLICATION_CONTENT &&
kind !== ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION &&
kind !== ExtendedKind.APPLICATION_HANDLER_INFO
)
const defaultShowKinds = PROFILE_FEED_KINDS
const storedShowKinds = storage.getShowKinds()
const storedShowKind1OPs = storage.getShowKind1OPs()
const storedShowKind1Replies = storage.getShowKind1Replies()

2
src/routes.tsx

@ -29,7 +29,7 @@ const ROUTES = [ @@ -29,7 +29,7 @@ const ROUTES = [
{ path: '/search/notes/:id', element: <NotePage /> },
{ path: '/profile/notes/:id', element: <NotePage /> },
{ path: '/explore/notes/:id', element: <NotePage /> },
{ path: '/notifications/notes/:id', element: <NotePage /> },
{ path: '/spells/notes/:id', element: <NotePage /> },
{ path: '/users', element: <ProfileListPage /> },
{ path: '/users/:id', element: <ProfilePage /> },
{ path: '/users/:id/following', element: <FollowingListPage /> },

Loading…
Cancel
Save