Browse Source

clean up codebase

imwald
Silberengel 1 month ago
parent
commit
bb60cc5c27
  1. 52
      package-lock.json
  2. 3
      package.json
  3. 56
      src/components/AccountManager/BunkerLogin.tsx
  4. 43
      src/components/BottomNavigationBar/AccountButton.tsx
  5. 143
      src/components/FeedSwitcher/index.tsx
  6. 25
      src/components/NotificationList/NotificationItem/DiscussionNotification.tsx
  7. 59
      src/components/NotificationList/NotificationItem/MentionNotification.tsx
  8. 147
      src/components/NotificationList/NotificationItem/Notification.tsx
  9. 30
      src/components/NotificationList/NotificationItem/PollResponseNotification.tsx
  10. 48
      src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx
  11. 60
      src/components/NotificationList/NotificationItem/ReactionNotification.tsx
  12. 32
      src/components/NotificationList/NotificationItem/RepostNotification.tsx
  13. 34
      src/components/NotificationList/NotificationItem/ZapNotification.tsx
  14. 68
      src/components/NotificationList/NotificationItem/index.tsx
  15. 438
      src/components/NotificationList/index.tsx
  16. 37
      src/components/PaneModeToggle/index.tsx
  17. 48
      src/components/ProfileCard/index.tsx
  18. 205
      src/components/SimpleNoteFeed/index.tsx
  19. 162
      src/components/ui/command.tsx
  20. 154
      src/hooks/useContentParser.tsx
  21. 64
      src/pages/primary/DiscussionsPage/SubtopicFilter.tsx
  22. 206
      src/pages/primary/DiscussionsPage/ThreadCard.tsx
  23. 47
      src/pages/primary/DiscussionsPage/ThreadSort.tsx
  24. 120
      src/pages/primary/DiscussionsPage/TopicFilter.tsx
  25. 67
      src/pages/primary/DiscussionsPage/ViewToggle.tsx
  26. 1265
      src/pages/primary/DiscussionsPage/index.tsx
  27. 10
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  28. 322
      src/services/client-cache.service.ts
  29. 55
      src/services/transaction.service.ts
  30. 286
      test-navigation-manual.js
  31. 40
      test-navigation.js

52
package-lock.json generated

@ -15,6 +15,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0", "@getalby/bitcoin-connect-react": "^3.10.0",
"@getalby/lightning-tools": "^6.1.0",
"@noble/hashes": "^1.6.1", "@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
@ -33,6 +34,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@scure/base": "^2.0.0",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tiptap/core": "^2.12.0", "@tiptap/core": "^2.12.0",
"@tiptap/extension-document": "^2.12.0", "@tiptap/extension-document": "^2.12.0",
@ -51,7 +53,6 @@
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dataloader": "^2.2.3", "dataloader": "^2.2.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
@ -4814,9 +4815,9 @@
] ]
}, },
"node_modules/@scure/base": { "node_modules/@scure/base": {
"version": "1.2.6", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
@ -4851,6 +4852,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip32/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": { "node_modules/@scure/bip39": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
@ -4876,15 +4886,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip39/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -6406,22 +6407,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -9081,15 +9066,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/nostr-tools/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32": { "node_modules/nostr-tools/node_modules/@scure/bip32": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",

3
package.json

@ -29,7 +29,9 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0", "@getalby/bitcoin-connect-react": "^3.10.0",
"@getalby/lightning-tools": "^6.1.0",
"@noble/hashes": "^1.6.1", "@noble/hashes": "^1.6.1",
"@scure/base": "^2.0.0",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@ -66,7 +68,6 @@
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dataloader": "^2.2.3", "dataloader": "^2.2.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",

56
src/components/AccountManager/BunkerLogin.tsx

@ -1,56 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function BunkerLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { bunkerLogin } = useNostr()
const [pending, setPending] = useState(false)
const [bunkerInput, setBunkerInput] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBunkerInput(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (bunkerInput === '') return
setPending(true)
bunkerLogin(bunkerInput)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message))
.finally(() => setPending(false))
}
return (
<>
<div className="space-y-1">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin' : 'hidden'} />
{t('Login')}
</Button>
<Button variant="secondary" onClick={back}>
{t('Back')}
</Button>
</>
)
}

43
src/components/BottomNavigationBar/AccountButton.tsx

@ -1,43 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Skeleton } from '@/components/ui/skeleton'
import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import { useMemo } from 'react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function AccountButton() {
const { navigate, current, display } = usePrimaryPage()
const { pubkey, profile } = useNostr()
const defaultAvatar = useMemo(
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
[profile]
)
const active = useMemo(() => current === 'profile' && display, [display, current])
return (
<BottomNavigationBarItem
onClick={() => {
navigate(pubkey ? 'profile' : 'me')
}}
active={active}
>
{pubkey ? (
profile ? (
<Avatar className={cn('w-7 h-7', active ? 'ring-primary ring-1' : '')}>
<AvatarImage src={profile.avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
) : (
<Skeleton className={cn('w-7 h-7 rounded-full', active ? 'ring-primary ring-1' : '')} />
)
) : (
<UserRound />
)}
</BottomNavigationBarItem>
)
}

143
src/components/FeedSwitcher/index.tsx

@ -1,143 +0,0 @@
import { toRelaySettings } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { SecondaryPageLink } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { BookmarkIcon, UsersRound, Server } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import RelaySetCard from '../RelaySetCard'
import logger from '@/lib/logger'
export default function FeedSwitcher({ close }: { close?: () => void }) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const { feedInfo, switchFeed } = useFeed()
// Filter out blocked relays for display
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
// Feed rows: aggregate favorites → following → bookmarks (see FAUX_SPELL_ORDER for spell picker order).
return (
<div className="space-y-2">
{visibleRelays.length > 0 && (
<FeedSwitcherItem
isActive={feedInfo.feedType === 'all-favorites'}
onClick={() => {
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">
<Server className="size-4" />
</div>
<div>{t('All favorite relays')}</div>
</div>
</FeedSwitcherItem>
)}
{pubkey && (
<FeedSwitcherItem
isActive={feedInfo.feedType === 'following'}
onClick={() => {
if (!pubkey) return
switchFeed('following', { pubkey })
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" />
</div>
<div>{t('Following')}</div>
</div>
</FeedSwitcherItem>
)}
{pubkey && (
<FeedSwitcherItem
isActive={feedInfo.feedType === 'bookmarks'}
onClick={() => {
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">
<BookmarkIcon className="size-4" />
</div>
<div>{t('Bookmarks')}</div>
</div>
</FeedSwitcherItem>
)}
<div className="flex justify-end items-center text-sm">
<SecondaryPageLink
to={toRelaySettings()}
className="text-primary font-semibold"
onClick={() => close?.()}
>
{t('edit')}
</SecondaryPageLink>
</div>
{relaySets
.filter((set) => set.relayUrls.length > 0)
.map((set) => (
<RelaySetCard
key={set.id}
relaySet={set}
select={feedInfo.feedType === 'relays' && set.id === feedInfo.id}
onSelectChange={(select) => {
if (!select) return
switchFeed('relays', { activeRelaySetId: set.id })
close?.()
}}
/>
))}
{visibleRelays.map((relay) => (
<FeedSwitcherItem
key={relay}
isActive={feedInfo.feedType === 'relay' && feedInfo.id === relay}
onClick={() => {
switchFeed('relay', { relay })
close?.()
}}
>
<div className="flex gap-2 items-center w-full">
<RelayIcon url={relay} />
<div className="flex-1 w-0 truncate">{simplifyUrl(relay)}</div>
</div>
</FeedSwitcherItem>
))}
</div>
)
}
function FeedSwitcherItem({
children,
isActive,
onClick,
controls
}: {
children: React.ReactNode
isActive: boolean
onClick: () => void
controls?: React.ReactNode
}) {
return (
<div
className={`w-full border rounded-lg p-4 ${isActive ? 'border-primary bg-primary/5' : 'clickable'}`}
onClick={onClick}
>
<div className="flex justify-between items-center">
<div className="font-semibold flex-1">{children}</div>
{controls}
</div>
</div>
)
}

25
src/components/NotificationList/NotificationItem/DiscussionNotification.tsx

@ -1,25 +0,0 @@
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function DiscussionNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
// Get the topic from t-tags
const topicTags = notification.tags.filter(tag => tag[0] === 't' && tag[1])
const topics = topicTags.map(tag => tag[1])
const topicString = topics.length > 0 ? topics.join(', ') : t('general')
return (
<Notification
sender={notification.pubkey}
sentAt={notification.created_at}
description={t('started a discussion in {{topic}}', { topic: topicString })}
icon={<MessageCircle className="w-4 h-4 text-primary" />}
targetEvent={notification}
showStats={false}
/>
)
}

59
src/components/NotificationList/NotificationItem/MentionNotification.tsx

@ -1,59 +0,0 @@
import ParentNotePreview from '@/components/ParentNotePreview'
import { NOTIFICATION_LIST_STYLE } from '@/constants'
import { getEmbeddedPubkeys, getParentBech32Id } from '@/lib/event'
import { toNote } from '@/lib/link'
import { useSmartNoteNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { AtSign, MessageCircle, Quote } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function MentionNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const { pubkey } = useNostr()
const { notificationListStyle } = useUserPreferences()
const isMention = useMemo(() => {
if (!pubkey) return false
const mentions = getEmbeddedPubkeys(notification)
return mentions.includes(pubkey)
}, [pubkey, notification])
const parentEventId = useMemo(() => getParentBech32Id(notification), [notification])
return (
<Notification
icon={
isMention ? (
<AtSign size={24} className="text-pink-400" />
) : parentEventId ? (
<MessageCircle size={24} className="text-blue-400" />
) : (
<Quote size={24} className="text-green-400" />
)
}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={notification}
middle={
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED &&
parentEventId && (
<ParentNotePreview
eventId={parentEventId}
className=""
onClick={(e) => {
e.stopPropagation()
navigateToNote(toNote(parentEventId))
}}
/>
)
}
description={
isMention ? t('mentioned you in a note') : parentEventId ? '' : t('quoted your note')
}
showStats
/>
)
}

147
src/components/NotificationList/NotificationItem/Notification.tsx

@ -1,147 +0,0 @@
import ContentPreview from '@/components/ContentPreview'
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import NoteStats from '@/components/NoteStats'
import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { NOTIFICATION_LIST_STYLE } from '@/constants'
import { toNote, toProfile } from '@/lib/link'
import client from '@/services/client.service'
import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { NostrEvent } from 'nostr-tools'
export default function Notification({
icon,
sender,
sentAt,
description,
middle = null,
targetEvent,
showStats = false,
rightAction = null
}: {
icon: React.ReactNode
sender: string
sentAt: number
description: string
middle?: React.ReactNode
targetEvent?: NostrEvent
showStats?: boolean
rightAction?: React.ReactNode
}) {
const { navigateToNote } = useSmartNoteNavigation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const { notificationListStyle } = useUserPreferences()
const handleClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) {
return
}
if (target.closest('[data-note-stats]')) {
return
}
const hasOpenModal = document.querySelector('[data-radix-dialog-content][data-state="open"]')
if (hasOpenModal) {
return
}
if (targetEvent) {
client.addEventToCache(targetEvent)
navigateToNote(toNote(targetEvent.id), targetEvent)
} else if (pubkey) {
push(toProfile(pubkey))
}
}
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) {
return (
<div
className="flex items-center justify-between cursor-pointer py-2 px-4"
onClick={handleClick}
>
<div className="flex gap-2 items-center flex-1 w-0">
<UserAvatar userId={sender} size="small" />
{icon}
{middle}
{targetEvent && (
<ContentPreview className="truncate flex-1 w-0 text-muted-foreground" event={targetEvent} />
)}
</div>
<div className="text-muted-foreground shrink-0">
<FormattedTimestamp timestamp={sentAt} short />
</div>
</div>
)
}
return (
<div
className="clickable flex items-start gap-2 cursor-pointer py-2 px-4 border-b"
onClick={handleClick}
>
<div className="flex gap-2 items-center mt-1.5">
{icon}
<UserAvatar userId={sender} size="medium" />
</div>
<div className="flex-1 w-0">
<div className="flex items-center justify-between gap-1">
<div className="flex gap-1 items-center">
<Username
userId={sender}
className="flex-1 max-w-fit truncate font-semibold"
skeletonClassName="h-4"
/>
<div className="shrink-0 text-muted-foreground text-sm">{description}</div>
</div>
<div className="flex items-center gap-1 shrink-0">{rightAction}</div>
</div>
{middle}
{targetEvent && (
<ContentPreview className={cn('line-clamp-2 text-muted-foreground')} event={targetEvent} />
)}
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" />
{showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />}
</div>
</div>
)
}
export function NotificationSkeleton() {
const { notificationListStyle } = useUserPreferences()
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) {
return (
<div className="flex gap-2 items-center h-11 py-2 px-4">
<Skeleton className="w-7 h-7 rounded-full" />
<Skeleton className="h-6 flex-1 w-0" />
</div>
)
}
return (
<div className="flex items-start gap-2 cursor-pointer py-2 px-4">
<div className="flex gap-2 items-center mt-1.5">
<Skeleton className="w-6 h-6" />
<Skeleton className="w-9 h-9 rounded-full" />
</div>
<div className="flex-1 w-0">
<div className="py-1">
<Skeleton className="w-16 h-4" />
</div>
<div className="py-1">
<Skeleton className="w-full h-4" />
</div>
<div className="py-1">
<Skeleton className="w-12 h-4" />
</div>
</div>
</div>
)
}

30
src/components/NotificationList/NotificationItem/PollResponseNotification.tsx

@ -1,30 +0,0 @@
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { Vote } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Notification from './Notification'
import { useTranslation } from 'react-i18next'
export function PollResponseNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const eventId = useMemo(() => {
const eTag = notification.tags.find(tagNameEquals('e'))
return eTag ? generateBech32IdFromETag(eTag) : undefined
}, [notification])
const { event: pollEvent } = useFetchEvent(eventId)
if (!pollEvent) {
return null
}
return (
<Notification
icon={<Vote size={24} className="text-violet-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={pollEvent}
description={t('voted in your poll')}
/>
)
}

48
src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx

@ -1,48 +0,0 @@
import { useNostr } from '@/providers/NostrProvider'
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function PublicMessageNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const isRecipient = useMemo(() => {
if (!pubkey) return false
// Check if current user is in the 'p' tags (recipients)
return notification.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey)
}, [pubkey, notification])
// Get list of recipients for display
const recipients = useMemo(() => {
return notification.tags
.filter((tag) => tag[0] === 'p')
.map((tag) => tag[1])
.slice(0, 3) // Show first 3 recipients
}, [notification.tags])
const description = useMemo(() => {
if (isRecipient) {
if (recipients.length > 1) {
return t('sent you a public message (along with {{count}} others)', {
count: recipients.length - 1
})
}
return t('sent you a public message')
}
return t('sent a public message')
}, [isRecipient, recipients.length, t])
return (
<Notification
icon={<MessageCircle size={24} className="text-purple-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={notification}
description={description}
showStats
/>
)
}

60
src/components/NotificationList/NotificationItem/ReactionNotification.tsx

@ -1,60 +0,0 @@
import Image from '@/components/Image'
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { Heart } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function ReactionNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const eventId = useMemo(() => {
const aTag = notification.tags.findLast(tagNameEquals('a'))
if (aTag) {
return generateBech32IdFromATag(aTag)
}
const eTag = notification.tags.findLast(tagNameEquals('e'))
return eTag ? generateBech32IdFromETag(eTag) : undefined
}, [notification, pubkey])
const { event } = useFetchEvent(eventId)
const reaction = useMemo(() => {
if (!notification.content || notification.content === '+') {
return <Heart size={24} className="text-red-400" />
}
const emojiName = /^:([^:]+):$/.exec(notification.content)?.[1]
if (emojiName) {
const emojiTag = notification.tags.find((tag) => tag[0] === 'emoji' && tag[1] === emojiName)
const emojiUrl = emojiTag?.[2]
if (emojiUrl) {
return (
<Image
image={{ url: emojiUrl, pubkey: notification.pubkey }}
alt={emojiName}
className="w-6 h-6"
classNames={{ errorPlaceholder: 'bg-transparent' }}
errorPlaceholder={<Heart size={24} className="text-red-400" />}
/>
)
}
}
return notification.content
}, [notification])
if (!event || !eventId) {
return null
}
return (
<Notification
icon={<div className="text-xl min-w-6 text-center">{reaction}</div>}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={event}
description={t('reacted to your note')}
/>
)
}

32
src/components/NotificationList/NotificationItem/RepostNotification.tsx

@ -1,32 +0,0 @@
import client from '@/services/client.service'
import { Repeat } from 'lucide-react'
import { Event, validateEvent } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function RepostNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const event = useMemo(() => {
try {
const event = JSON.parse(notification.content) as Event
const isValid = validateEvent(event)
if (!isValid) return null
client.addEventToCache(event)
return event
} catch {
return null
}
}, [notification.content])
if (!event) return null
return (
<Notification
icon={<Repeat size={24} className="text-green-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={event}
description={t('boosted your note')}
/>
)
}

34
src/components/NotificationList/NotificationItem/ZapNotification.tsx

@ -1,34 +0,0 @@
import { useFetchEvent } from '@/hooks'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { formatAmount } from '@/lib/lightning'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function ZapNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const { senderPubkey, eventId, amount, comment } = useMemo(
() => getZapInfoFromEvent(notification) ?? ({} as any),
[notification]
)
const { event } = useFetchEvent(eventId)
if (!senderPubkey || !amount) return null
return (
<Notification
icon={<Zap size={24} className="text-yellow-400 shrink-0" />}
sender={senderPubkey}
sentAt={notification.created_at}
targetEvent={event}
middle={
<div className="font-semibold text-yellow-400 truncate">
{formatAmount(amount)} {t('sats')} {comment}
</div>
}
description={event ? t('zapped your note') : t('zapped you')}
/>
)
}

68
src/components/NotificationList/NotificationItem/index.tsx

@ -1,68 +0,0 @@
import { ExtendedKind } from '@/constants'
import { notificationFilter } from '@/lib/notification'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { DiscussionNotification } from './DiscussionNotification'
import { MentionNotification } from './MentionNotification'
import { PollResponseNotification } from './PollResponseNotification'
import { PublicMessageNotification } from './PublicMessageNotification'
import { ReactionNotification } from './ReactionNotification'
import { RepostNotification } from './RepostNotification'
import { ZapNotification } from './ZapNotification'
export function NotificationItem({ notification }: { notification: Event }) {
const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const canShow = useMemo(() => {
const result = notificationFilter(notification, {
pubkey,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted
})
return result
}, [
notification,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted
])
if (!canShow) return null
if (notification.kind === 11) {
return <DiscussionNotification notification={notification} />
}
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} />
}
if (notification.kind === ExtendedKind.PUBLIC_MESSAGE) {
return <PublicMessageNotification notification={notification} />
}
if (
notification.kind === kinds.ShortTextNote ||
notification.kind === ExtendedKind.COMMENT ||
notification.kind === ExtendedKind.VOICE_COMMENT ||
notification.kind === ExtendedKind.POLL
) {
return <MentionNotification notification={notification} />
}
if (notification.kind === kinds.Repost) {
return <RepostNotification notification={notification} />
}
if (notification.kind === kinds.Zap) {
return <ZapNotification notification={notification} />
}
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
return <PollResponseNotification notification={notification} />
}
return null
}

438
src/components/NotificationList/index.tsx

@ -1,438 +0,0 @@
import { ExtendedKind, NOTIFICATION_LIST_STYLE, FAST_READ_RELAY_URLS } from '@/constants'
import { compareEvents } from '@/lib/event'
import logger from '@/lib/logger'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import { TNotificationType } from '@/types'
import dayjs from 'dayjs'
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import { NotificationItem } from './NotificationItem'
import { NotificationSkeleton } from './NotificationItem/Notification'
import { isTouchDevice } from '@/lib/utils'
const LIMIT = 500 // Increased from 100 to load more notifications per request
const SHOW_COUNT = 50 // Increased from 30 to show more notifications at once
const NotificationList = forwardRef(
(
{
notificationType
}: {
notificationType: TNotificationType
},
ref
) => {
const { t } = useTranslation()
const { display } = usePrimaryPage()
const active = display
const { pubkey, relayList } = useNostr()
const { notificationListStyle } = useUserPreferences()
const { favoriteRelays } = useFavoriteRelays()
const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true)
const [notifications, setNotifications] = useState<NostrEvent[]>([])
const [visibleNotifications, setVisibleNotifications] = useState<NostrEvent[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
const supportTouch = useMemo(() => isTouchDevice(), [])
const topRef = useRef<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent premature stopping
const filterKinds = useMemo(() => {
switch (notificationType) {
case 'mentions':
return [
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL,
ExtendedKind.PUBLIC_MESSAGE,
11 // Discussion threads
]
case 'reactions':
return [kinds.Reaction, kinds.Repost, ExtendedKind.POLL_RESPONSE]
case 'zaps':
return [kinds.Zap]
default:
return [
kinds.ShortTextNote,
kinds.Repost,
kinds.Reaction,
kinds.Zap,
ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL,
ExtendedKind.PUBLIC_MESSAGE,
11 // Discussion threads
]
}
}, [notificationType])
useImperativeHandle(
ref,
() => ({
refresh: () => {
if (loading) return
setRefreshCount((count) => count + 1)
}
}),
[loading]
)
// Reset visible count when tab changes (parent owns tab state)
useEffect(() => {
setShowCount(SHOW_COUNT)
}, [notificationType])
// Batch stats updates to avoid calling updateNoteStatsByEvents for every single event
const pendingStatsEventsRef = useRef<NostrEvent[]>([])
const statsBatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const flushStatsBatch = useCallback(() => {
if (pendingStatsEventsRef.current.length > 0) {
noteStatsService.updateNoteStatsByEvents(pendingStatsEventsRef.current)
pendingStatsEventsRef.current = []
}
if (statsBatchTimeoutRef.current) {
clearTimeout(statsBatchTimeoutRef.current)
statsBatchTimeoutRef.current = null
}
}, [])
const handleNewEvent = useCallback(
(event: NostrEvent) => {
if (event.pubkey === pubkey) return
setNotifications((oldEvents) => {
// Check if event already exists
const existingIndex = oldEvents.findIndex((oldEvent) => oldEvent.id === event.id)
if (existingIndex !== -1) {
return oldEvents // Already exists, don't update
}
const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0)
// Batch stats updates instead of calling for each event
pendingStatsEventsRef.current.push(event)
if (!statsBatchTimeoutRef.current) {
statsBatchTimeoutRef.current = setTimeout(flushStatsBatch, 500) // Batch every 500ms
}
if (index === -1) {
return [...oldEvents, event]
}
return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)]
})
},
[pubkey, flushStatsBatch]
)
useEffect(() => {
if (!pubkey) {
setUntil(undefined)
return
}
const init = async () => {
setLoading(true)
setNotifications([])
setShowCount(SHOW_COUNT)
// Use proper fallback hierarchy: user's read/inbox relays → favorite relays → fast read relays
const userRelayList = relayList || { read: [], write: [] }
const userReadRelays = userRelayList.read || []
const userFavoriteRelays = favoriteRelays || []
// Build relay list with proper fallback hierarchy
let primaryRelays: string[] = []
if (userReadRelays.length > 0) {
// Priority 1: User's read/inbox relays (kind 10002)
primaryRelays = userReadRelays.slice(0, 5)
logger.component('NotificationList', 'Using user read relays', {
count: primaryRelays.length,
relays: primaryRelays.slice(0, 3) // Show first 3 for brevity
})
} else if (userFavoriteRelays.length > 0) {
// Priority 2: User's favorite relays (kind 10012)
primaryRelays = userFavoriteRelays.slice(0, 5)
logger.component('NotificationList', 'Using user favorite relays', {
count: primaryRelays.length,
relays: primaryRelays.slice(0, 3) // Show first 3 for brevity
})
} else {
// Priority 3: Fast read relays (reliable defaults)
primaryRelays = FAST_READ_RELAY_URLS.slice(0, 5)
logger.component('NotificationList', 'Using fast read relays fallback', {
count: primaryRelays.length,
relays: primaryRelays.slice(0, 3) // Show first 3 for brevity
})
}
// Create a single optimized subscription for all notification types
const subscriptions = [{
urls: primaryRelays,
filter: {
kinds: filterKinds,
limit: LIMIT,
'#p': [pubkey] // Always filter for mentions to the current user
}
}]
const { closer, timelineKey } = await client.subscribeTimeline(
subscriptions,
{
onEvents: (events, eosed) => {
if (events.length > 0) {
setNotifications(events.filter((event) => event.pubkey !== pubkey))
}
if (eosed) {
setLoading(false)
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
// Batch stats update for initial load - only process events that don't have stats yet
// This avoids redundant processing since updateNoteStatsByEvents is idempotent but still expensive
if (events.length > 0) {
noteStatsService.updateNoteStatsByEvents(events)
}
}
},
onNew: (event) => {
handleNewEvent(event)
}
},
{
useCache: false // Notifications should always fetch fresh from relays, not use cache
}
)
setTimelineKey(timelineKey)
return closer
}
const promise = init()
return () => {
promise.then((closer) => closer?.())
// Clean up stats batch timeout on unmount
if (statsBatchTimeoutRef.current) {
clearTimeout(statsBatchTimeoutRef.current)
statsBatchTimeoutRef.current = null
}
flushStatsBatch() // Flush any pending stats updates
consecutiveEmptyRef.current = 0 // Reset counter on refresh
}
}, [pubkey, refreshCount, filterKinds, relayList, favoriteRelays, flushStatsBatch])
useEffect(() => {
if (!active || !pubkey) return
const handler = (data: Event) => {
const customEvent = data as CustomEvent<NostrEvent>
const evt = customEvent.detail
if (
matchFilter(
{
kinds: filterKinds,
'#p': [pubkey]
},
evt
)
) {
handleNewEvent(evt)
}
}
client.addEventListener('newEvent', handler)
return () => {
client.removeEventListener('newEvent', handler)
}
}, [pubkey, active, filterKinds, handleNewEvent])
useEffect(() => {
setVisibleNotifications(notifications.slice(0, showCount))
}, [notifications, showCount])
// Use refs to avoid infinite loops from dependency changes
const notificationsRef = useRef(notifications)
const showCountRef = useRef(showCount)
const loadingRef = useRef(loading)
useEffect(() => {
notificationsRef.current = notifications
}, [notifications])
useEffect(() => {
showCountRef.current = showCount
}, [showCount])
useEffect(() => {
loadingRef.current = loading
}, [loading])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
const loadMore = async () => {
// Use refs to avoid dependency on notifications/showCount/loading
const currentNotifications = notificationsRef.current
const currentShowCount = showCountRef.current
const currentLoading = loadingRef.current
if (currentShowCount < currentNotifications.length) {
// Show more aggressively: increase by SHOW_COUNT, but also check if we should show even more
const remaining = currentNotifications.length - currentShowCount
const increment = Math.min(SHOW_COUNT * 2, remaining) // Show up to 2x SHOW_COUNT if available
setShowCount((count) => count + increment)
// Only preload more if we have plenty cached (more than 3/4 of LIMIT)
// BUT: Always try to load more if we have very few notifications (might be due to filtering)
if (currentNotifications.length - currentShowCount > LIMIT * 0.75 && currentNotifications.length >= 50) {
return
}
// If we have very few notifications, always try to load more (might be aggressive filtering)
if (currentNotifications.length < 50) {
// Continue to loadMore below even if we have cached notifications
// This ensures we keep loading when filtering is aggressive
}
}
if (!pubkey || !timelineKey || !until || currentLoading) return
setLoading(true)
try {
const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
// CRITICAL FIX: Don't stop immediately on empty results - might be temporary relay issues
// Only stop if we've tried many times with no results
if (newNotifications.length === 0) {
// Check if timeline has more cached refs that we haven't loaded yet
const hasMoreCached = client.hasMoreTimelineEvents?.(timelineKey, until) ?? false
if (hasMoreCached) {
// There are more cached notifications, keep trying
consecutiveEmptyRef.current = 0 // Reset counter when we have cached events
setLoading(false)
// Retry after a short delay to allow IndexedDB to catch up
setTimeout(() => {
if (until) {
loadMore()
}
}, 300)
return
}
// No cached notifications and network returned empty
// Be patient - don't stop too early, especially when we have few notifications
consecutiveEmptyRef.current += 1
// Only stop after MANY consecutive empty results (similar to NoteList)
if (consecutiveEmptyRef.current >= 20) {
// After 20 consecutive empty results, assume we've reached the end
setUntil(undefined)
setLoading(false)
return
}
// Otherwise, keep trying on next scroll
setLoading(false)
return
}
// Reset consecutive empty counter on success
consecutiveEmptyRef.current = 0
if (newNotifications.length > 0) {
setNotifications((oldNotifications) => [
...oldNotifications,
...newNotifications.filter((event) => event.pubkey !== pubkey)
])
}
setUntil(newNotifications[newNotifications.length - 1].created_at - 1)
} catch (error) {
// On error, don't stop immediately - might be temporary network issue
logger.error('[NotificationList] Error loading more notifications', { error })
consecutiveEmptyRef.current += 1
// Only stop after MANY consecutive errors - be very patient with network issues
if (consecutiveEmptyRef.current >= 25) {
setUntil(undefined)
}
} finally {
setLoading(false)
}
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [pubkey, timelineKey, until]) // Removed notifications, showCount, loading to prevent infinite loops
const refresh = () => {
topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' })
consecutiveEmptyRef.current = 0 // Reset counter on refresh
setTimeout(() => {
setRefreshCount((count) => count + 1)
}, 500)
}
const list = (
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
{visibleNotifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} />
))}
<div className="text-center text-sm text-muted-foreground">
{until || loading ? (
<div ref={bottomRef}>
<NotificationSkeleton />
</div>
) : (
t('no more notifications')
)}
</div>
</div>
)
return (
<div>
<div ref={topRef} />
{supportTouch ? (
<PullToRefresh
onRefresh={async () => {
refresh()
await new Promise((resolve) => setTimeout(resolve, 1000))
}}
pullingContent=""
>
{list}
</PullToRefresh>
) : (
list
)}
</div>
)
}
)
NotificationList.displayName = 'NotificationList'
export default NotificationList

37
src/components/PaneModeToggle/index.tsx

@ -1,37 +0,0 @@
import { Button } from '@/components/ui/button'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import storage from '@/services/local-storage.service'
import { PanelLeft, PanelsLeftRight } from 'lucide-react'
import { useState } from 'react'
export default function PaneModeToggle() {
const { isSmallScreen } = useScreenSize()
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode())
// Hide on mobile
if (isSmallScreen) return null
const toggleMode = () => {
const newMode = panelMode === 'single' ? 'double' : 'single'
setPanelMode(newMode)
storage.setPanelMode(newMode)
}
return (
<Button
variant="ghost"
className="flex shadow-none items-center transition-colors duration-500 bg-transparent w-12 h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-3 rounded-lg xl:justify-start gap-4 text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4"
title={panelMode === 'single' ? 'Switch to double-pane mode' : 'Switch to single-pane mode'}
onClick={toggleMode}
>
{panelMode === 'single' ? (
<PanelLeft strokeWidth={3} />
) : (
<PanelsLeftRight strokeWidth={3} />
)}
<div className="max-xl:hidden">
{panelMode === 'single' ? 'Single-pane' : 'Double-pane'}
</div>
</Button>
)
}

48
src/components/ProfileCard/index.tsx

@ -1,48 +0,0 @@
import { Button } from '@/components/ui/button'
import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link'
import { useSmartProfileNavigation } from '@/PageManager'
import { UserRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import FollowButton from '../FollowButton'
import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
import { SimpleUserAvatar } from '../UserAvatar'
export default function ProfileCard({ pubkey }: { pubkey: string }) {
const { profile } = useFetchProfile(pubkey)
const { username, about } = profile || {}
const { navigateToProfile } = useSmartProfileNavigation()
const { t } = useTranslation()
return (
<div className="w-full flex flex-col gap-2 not-prose">
<div className="flex space-x-2 w-full items-start justify-between">
<SimpleUserAvatar userId={pubkey} className="w-12 h-12" />
<FollowButton pubkey={pubkey} />
</div>
<div>
<div className="text-lg font-semibold truncate">{username}</div>
<Nip05 pubkey={pubkey} />
</div>
{about && (
<ProfileAbout
about={about}
className="text-sm text-wrap break-words w-full overflow-hidden text-ellipsis line-clamp-6"
/>
)}
<Button
variant="outline"
size="sm"
className="w-full mt-2"
onClick={(e) => {
e.stopPropagation()
navigateToProfile(toProfile(pubkey))
}}
>
<UserRound className="w-4 h-4 mr-2" />
{t('View full profile')}
</Button>
</div>
)
}

205
src/components/SimpleNoteFeed/index.tsx

@ -1,205 +0,0 @@
import { forwardRef, useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react'
import { useNostr } from '@/providers/NostrProvider'
import { normalizeUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants'
import client from '@/services/client.service'
import { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
import logger from '@/lib/logger'
import NoteCard from '@/components/NoteCard'
type TSimpleNoteFeedProps = {
authors?: string[]
kinds?: number[]
limit?: number
hideReplies?: boolean
filterMutedNotes?: boolean
customHeader?: React.ReactNode
}
const SimpleNoteFeed = forwardRef<
{ refresh: () => void },
TSimpleNoteFeedProps
>(({
authors = [],
kinds: requestedKinds = [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, kinds.LongFormArticle],
limit = 100,
hideReplies = false,
filterMutedNotes = false,
customHeader
}, ref) => {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
logger.component('SimpleNoteFeed', 'Component rendered', { authors, requestedKinds, limit, hideReplies, pubkey: !!pubkey })
// Build comprehensive relay list (same as Discussions)
const buildComprehensiveRelayList = useCallback(async () => {
const myRelayList = pubkey ? await client.fetchRelayList(pubkey) : { write: [], read: [] }
const allRelays = [
...(myRelayList.read || []), // User's inboxes (kind 10002)
...(myRelayList.write || []), // User's outboxes (kind 10002)
...FAST_READ_RELAY_URLS, // Fast read relays
]
// Normalize and deduplicate relay URLs
const normalizedRelays = allRelays
.map(url => normalizeUrl(url))
.filter((url): url is string => !!url)
logger.debug('[SimpleNoteFeed] Using', normalizedRelays.length, 'comprehensive relays')
return Array.from(new Set(normalizedRelays))
}, [pubkey])
// Fetch events using the same pattern as Discussions
const fetchEvents = useCallback(async () => {
if (isRefreshing) {
logger.component('SimpleNoteFeed', 'Already refreshing, skipping')
return
}
logger.component('SimpleNoteFeed', 'Starting fetch', { authors, kinds: requestedKinds, limit })
setLoading(true)
setIsRefreshing(true)
try {
// Get comprehensive relay list
const allRelays = await buildComprehensiveRelayList()
logger.component('SimpleNoteFeed', 'Using relays', { count: allRelays.length })
// Build filter
const filter: any = {
kinds: requestedKinds,
limit
}
if (authors.length > 0) {
filter.authors = authors
}
logger.component('SimpleNoteFeed', 'Using filter', filter)
// Fetch events
logger.component('SimpleNoteFeed', 'Calling client.fetchEvents')
const { queryService } = await import('@/services/client.service')
const fetchedEvents = await queryService.fetchEvents(allRelays, [filter], {
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS
})
logger.component('SimpleNoteFeed', 'Fetched events', { count: fetchedEvents.length })
// Deduplicate events by ID (same event might come from different relays)
const seenIds = new Set<string>()
const uniqueEvents = fetchedEvents.filter(event => {
if (seenIds.has(event.id)) {
return false
}
seenIds.add(event.id)
return true
})
logger.component('SimpleNoteFeed', 'Deduplicated events', { count: uniqueEvents.length })
// Filter events (basic filtering)
const filteredEvents = uniqueEvents.filter(event => {
// Skip deleted events
if (event.content === '') return false
// Skip replies if hideReplies is true
if (hideReplies && event.tags.some(tag => tag[0] === 'e' && tag[1])) {
return false
}
return true
})
logger.component('SimpleNoteFeed', 'Filtered events', { count: filteredEvents.length })
setEvents(filteredEvents)
logger.component('SimpleNoteFeed', 'Set events successfully', { count: filteredEvents.length })
} catch (error) {
logger.component('SimpleNoteFeed', 'Error fetching events', { error: (error as Error).message })
// Don't clear events on error, keep what we have
} finally {
logger.component('SimpleNoteFeed', 'Setting loading states to false')
setLoading(false)
setIsRefreshing(false)
}
}, [authors, requestedKinds, limit, hideReplies, isRefreshing])
// Initial fetch
useEffect(() => {
logger.component('SimpleNoteFeed', 'useEffect triggered for initial fetch', { authors, requestedKinds, limit, hideReplies })
fetchEvents()
}, [authors, requestedKinds, limit, hideReplies])
// Expose refresh method
useEffect(() => {
if (ref && typeof ref === 'object') {
ref.current = {
refresh: fetchEvents
}
}
}, [ref, fetchEvents])
const handleRefresh = () => {
logger.component('SimpleNoteFeed', 'handleRefresh called')
fetchEvents()
}
if (loading && events.length === 0) {
return (
<div className="min-h-screen">
{customHeader}
<div className="flex items-center justify-center p-8">
<div className="text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-muted-foreground">{t('loading...')}</p>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen">
{customHeader}
{/* Events list */}
{events.length > 0 ? (
<div className="space-y-4">
{events.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={filterMutedNotes}
/>
))}
</div>
) : (
<div className="flex justify-center w-full mt-8">
<div className="text-center">
<p className="text-muted-foreground mb-4">{t('no notes found')}</p>
<button
onClick={handleRefresh}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
{t('reload notes')}
</button>
</div>
</div>
)}
</div>
)
})
SimpleNoteFeed.displayName = 'SimpleNoteFeed'
export default SimpleNoteFeed

162
src/components/ui/command.tsx

@ -1,162 +0,0 @@
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import * as React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({
children,
classNames,
...props
}: DialogProps & { classNames?: { content?: string } }) => {
return (
<Dialog {...props}>
<DialogContent
className={cn(
'overflow-hidden p-0 shadow-lg top-4 translate-y-0 data-[state=closed]:slide-out-to-top-4 data-[state=open]:slide-in-from-top-4',
classNames?.content
)}
>
<DialogHeader className="sr-only">
<DialogTitle>Command Menu</DialogTitle>
<DialogDescription>Search and select a command</DialogDescription>
</DialogHeader>
<Command
shouldFilter={false}
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 pr-6',
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> & { scrollAreaClassName?: string }
>(({ className, scrollAreaClassName, ...props }, ref) => (
<ScrollArea className={scrollAreaClassName}>
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
</ScrollArea>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut
}

154
src/hooks/useContentParser.tsx

@ -1,154 +0,0 @@
/**
* React hook for content parsing
*/
import { useState, useEffect } from 'react'
import { Event } from 'nostr-tools'
import { contentParserService, ParsedContent, ParseOptions } from '@/services/content-parser.service'
export interface UseContentParserOptions extends ParseOptions {
autoParse?: boolean
}
export interface UseContentParserReturn {
parsedContent: ParsedContent | null
isLoading: boolean
error: Error | null
parse: () => Promise<void>
}
/**
* Hook for parsing content with automatic detection and processing
*/
export function useContentParser(
content: string,
options: UseContentParserOptions = {}
): UseContentParserReturn {
const { autoParse = true, ...parseOptions } = options
const [parsedContent, setParsedContent] = useState<ParsedContent | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const parse = async () => {
if (!content.trim()) {
setParsedContent(null)
return
}
try {
setIsLoading(true)
setError(null)
const result = await contentParserService.parseContent(content, parseOptions)
setParsedContent(result)
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown parsing error'))
setParsedContent(null)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (autoParse) {
parse()
}
}, [content, autoParse, JSON.stringify(parseOptions)])
return {
parsedContent,
isLoading,
error,
parse
}
}
/**
* Hook for parsing Nostr event fields
*/
export function useEventFieldParser(
event: Event,
field: 'content' | 'title' | 'summary' | 'description',
options: Omit<UseContentParserOptions, 'eventKind' | 'field'> = {}
): UseContentParserReturn {
const [parsedContent, setParsedContent] = useState<ParsedContent | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const { autoParse = true, ...parseOptions } = options
const parse = async () => {
try {
setIsLoading(true)
setError(null)
const result = await contentParserService.parseEventField(event, field, parseOptions)
setParsedContent(result)
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown parsing error'))
setParsedContent(null)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (autoParse) {
parse()
}
}, [event.id, field, autoParse, JSON.stringify(parseOptions)])
return {
parsedContent,
isLoading,
error,
parse
}
}
/**
* Hook for parsing multiple event fields at once
*/
export function useEventFieldsParser(
event: Event,
fields: Array<'content' | 'title' | 'summary' | 'description'>,
options: Omit<UseContentParserOptions, 'eventKind' | 'field'> = {}
) {
const [parsedFields, setParsedFields] = useState<Record<string, ParsedContent | null>>({})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const { autoParse = true, ...parseOptions } = options
const parse = async () => {
try {
setIsLoading(true)
setError(null)
const results: Record<string, ParsedContent | null> = {}
for (const field of fields) {
const result = await contentParserService.parseEventField(event, field, parseOptions)
results[field] = result
}
setParsedFields(results)
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown parsing error'))
setParsedFields({})
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (autoParse) {
parse()
}
}, [event.id, JSON.stringify(fields), autoParse, JSON.stringify(parseOptions)])
return {
parsedFields,
isLoading,
error,
parse
}
}

64
src/pages/primary/DiscussionsPage/SubtopicFilter.tsx

@ -1,64 +0,0 @@
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface SubtopicFilterProps {
subtopics: string[]
selectedSubtopic: string | null
onSubtopicChange: (subtopic: string | null) => void
}
export default function SubtopicFilter({
subtopics,
selectedSubtopic,
onSubtopicChange
}: SubtopicFilterProps) {
const { t } = useTranslation()
if (subtopics.length === 0) return null
const formatSubtopicLabel = (subtopic: string): string => {
return subtopic
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
return (
<div className="flex gap-2 flex-wrap items-center">
<span className="text-sm text-muted-foreground">{t('Filter by')}:</span>
<Badge
variant={selectedSubtopic === null ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => onSubtopicChange(null)}
>
{t('All')}
</Badge>
{subtopics.map(subtopic => (
<Badge
key={subtopic}
variant={selectedSubtopic === subtopic ? 'default' : 'outline'}
className="cursor-pointer flex items-center gap-1"
onClick={() => onSubtopicChange(subtopic)}
>
{formatSubtopicLabel(subtopic)}
{selectedSubtopic === subtopic && (
<Button
variant="ghost"
size="icon"
className="h-3 w-3 p-0 hover:bg-transparent"
onClick={(e) => {
e.stopPropagation()
onSubtopicChange(null)
}}
>
<X className="h-2 w-2" />
</Button>
)}
</Badge>
))}
</div>
)
}

206
src/pages/primary/DiscussionsPage/ThreadCard.tsx

@ -1,206 +0,0 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Clock, Hash, Users } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { DISCUSSION_TOPICS } from './discussionTopics'
import Username from '@/components/Username'
import UserAvatar from '@/components/UserAvatar'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { extractAllTopics, extractGroupInfo } from '@/lib/discussion-topics'
import { removeEmojis } from '@/lib/utils'
interface ThreadCardProps {
thread: NostrEvent
onThreadClick: () => void
className?: string
lastCommentTime?: number
lastVoteTime?: number
upVotes?: number
downVotes?: number
}
export default function ThreadCard({
thread,
onThreadClick,
className,
lastCommentTime = 0,
lastVoteTime = 0,
upVotes = 0,
downVotes = 0
}: ThreadCardProps) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
// Extract title from tags and remove emojis
const titleTag = thread.tags.find(tag => tag[0] === 'title' && tag[1])
const rawTitle = titleTag?.[1] || t('Untitled')
const title = removeEmojis(rawTitle) || t('Untitled')
// Get topic info
const topicTag = thread.tags.find(tag => tag[0] === 't' && tag[1])
const topic = topicTag?.[1] || 'general'
const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topic) || {
id: topic,
label: topic,
icon: Hash
}
// Extract group information
const groupInfo = extractGroupInfo(thread, ['unknown'])
// Get all topics from this thread
const allTopics = extractAllTopics(thread)
// Format creation time (fromNow() includes suffix e.g. "3 hours ago")
const timeAgo = dayjs.unix(thread.created_at).fromNow()
// Format last activity times
const formatLastActivity = (timestamp: number) => {
if (timestamp === 0) return null
return dayjs.unix(timestamp).fromNow()
}
const lastCommentAgo = formatLastActivity(lastCommentTime)
const lastVoteAgo = formatLastActivity(lastVoteTime)
// Vote counts are no longer displayed, keeping variables for potential future use
// Get content preview - remove emojis first, then truncate
const contentWithoutEmojis = removeEmojis(thread.content)
const contentPreview = contentWithoutEmojis.length > 250
? contentWithoutEmojis.substring(0, 250) + '...'
: contentWithoutEmojis
return (
<Card
className={cn(
'clickable hover:shadow-md transition-shadow cursor-pointer',
className
)}
onClick={onThreadClick}
>
<CardHeader className="pb-3">
{isSmallScreen ? (
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="flex flex-col items-center gap-1">
<div className="text-green-600 font-semibold text-sm">+{upVotes || 0}</div>
<div className="text-red-600 font-semibold text-sm">-{downVotes || 0}</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg leading-tight line-clamp-2 mb-2 break-words">
{title}
</h3>
<div className="flex items-center flex-wrap gap-2 text-sm text-muted-foreground mb-2">
<div className="flex items-center gap-1">
<topicInfo.icon className="w-4 h-4" />
<span className="text-xs">{topicInfo.id}</span>
</div>
{allTopics.slice(0, 3).map(topic => (
<Badge key={topic} variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" />
{topic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}
</Badge>
))}
</div>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<div className="mb-2">
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
</div>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<UserAvatar userId={thread.pubkey} size="xSmall" />
<Username
userId={thread.pubkey}
className="truncate font-medium"
skeletonClassName="h-4 w-20"
/>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>{timeAgo}</span>
</div>
{/* Last updated */}
<div className="text-xs text-muted-foreground">
{t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo}
</div>
</div>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="flex flex-col items-center gap-1">
<div className="text-green-600 font-semibold text-sm">+{upVotes || 0}</div>
<div className="text-red-600 font-semibold text-sm">-{downVotes || 0}</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-semibold text-lg leading-tight line-clamp-2 break-words">
{title}
</h3>
</div>
<div className="flex items-center flex-wrap gap-2 text-sm text-muted-foreground mb-2">
<Badge variant="secondary" className="text-xs">
<topicInfo.icon className="w-3 h-3 mr-1" />
{topicInfo.label}
</Badge>
{allTopics.slice(0, 3).map(topic => (
<Badge key={topic} variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" />
{topic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}
</Badge>
))}
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{timeAgo}
</div>
{/* Last updated */}
<div className="text-xs text-muted-foreground">
{t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo}
</div>
</div>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<div className="mb-2">
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground shrink-0">
<UserAvatar userId={thread.pubkey} size="xSmall" />
<Username
userId={thread.pubkey}
className="truncate font-medium"
skeletonClassName="h-4 w-20"
/>
</div>
</div>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="text-sm text-muted-foreground leading-relaxed break-words overflow-hidden">
{contentPreview}
</div>
</CardContent>
</Card>
)
}

47
src/pages/primary/DiscussionsPage/ThreadSort.tsx

@ -1,47 +0,0 @@
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ChevronDown, Clock, TrendingUp, ArrowUpDown, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export type SortOption = 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped'
export default function ThreadSort({ selectedSort, onSortChange }: { selectedSort: SortOption; onSortChange: (sort: SortOption) => void }) {
const { t } = useTranslation()
const sortOptions = [
{ id: 'newest' as SortOption, label: t('Newest'), icon: Clock },
{ id: 'oldest' as SortOption, label: t('Oldest'), icon: Clock },
{ id: 'top' as SortOption, label: t('Top'), icon: TrendingUp },
{ id: 'controversial' as SortOption, label: t('Controversial'), icon: ArrowUpDown },
{ id: 'most-zapped' as SortOption, label: t('Most Zapped'), icon: Zap },
]
const selectedOption = sortOptions.find(option => option.id === selectedSort) || sortOptions[0]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-2 h-8">
<selectedOption.icon className="w-4 h-4" />
<span className="text-sm">{selectedOption.label}</span>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{sortOptions.map(option => (
<DropdownMenuItem
key={option.id}
onClick={() => onSortChange(option.id)}
className="flex items-center gap-2"
>
<option.icon className="w-4 h-4" />
<span>{option.label}</span>
{option.id === selectedSort && (
<span className="ml-auto text-primary"></span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

120
src/pages/primary/DiscussionsPage/TopicFilter.tsx

@ -1,120 +0,0 @@
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ChevronDown, Grid3X3, Users } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
interface Topic {
id: string
label: string
icon: any
}
interface TopicFilterProps {
topics: Topic[]
selectedTopic: string
onTopicChange: (topicId: string) => void
threads: NostrEvent[]
replies: NostrEvent[]
}
export default function TopicFilter({ topics, selectedTopic, onTopicChange, threads, replies }: TopicFilterProps) {
const { t } = useTranslation()
// Sort topics by activity (most recent kind 11 or kind 1111 events first)
const sortedTopics = useMemo(() => {
const allEvents = [...threads, ...replies]
return [...topics].sort((a, b) => {
// Find the most recent event for each topic
const getMostRecentEvent = (topicId: string) => {
return allEvents
.filter(event => {
const topicTag = event.tags.find(tag => tag[0] === 't' && tag[1] === topicId)
return topicTag !== undefined
})
.sort((a, b) => b.created_at - a.created_at)[0]
}
const mostRecentA = getMostRecentEvent(a.id)
const mostRecentB = getMostRecentEvent(b.id)
// If one has events and the other doesn't, prioritize the one with events
if (mostRecentA && !mostRecentB) return -1
if (!mostRecentA && mostRecentB) return 1
if (!mostRecentA && !mostRecentB) return 0 // Both have no events, keep original order
// Sort by creation time (most recent first)
return mostRecentB!.created_at - mostRecentA!.created_at
})
}, [topics, threads, replies])
// Create all topics option
const allTopicsOption = { id: 'all', label: t('All Topics'), icon: Grid3X3 }
// Create groups option if there are group discussions
const hasGroupDiscussions = threads.some(thread =>
thread.tags.some(tag => tag[0] === 'h' && tag[1])
)
const groupsOption = hasGroupDiscussions ? { id: 'groups', label: t('Groups'), icon: Users } : null
const selectedTopicInfo = selectedTopic === 'all'
? allTopicsOption
: selectedTopic === 'groups' && groupsOption
? groupsOption
: sortedTopics.find(topic => topic.id === selectedTopic) || sortedTopics[0]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 h-10 px-3 min-w-44"
>
<span className="flex-1 text-left">{selectedTopicInfo.label}</span>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
<DropdownMenuItem
key="all"
onClick={() => onTopicChange('all')}
className="flex items-center gap-2"
>
<Grid3X3 className="w-4 h-4" />
<span>{t('All Topics')}</span>
{selectedTopic === 'all' && (
<span className="ml-auto text-primary"></span>
)}
</DropdownMenuItem>
{groupsOption && (
<DropdownMenuItem
key="groups"
onClick={() => onTopicChange('groups')}
className="flex items-center gap-2"
>
<Users className="w-4 h-4" />
<span>{t('Groups')}</span>
{selectedTopic === 'groups' && (
<span className="ml-auto text-primary"></span>
)}
</DropdownMenuItem>
)}
{sortedTopics.map(topic => (
<DropdownMenuItem
key={topic.id}
onClick={() => onTopicChange(topic.id)}
className="flex items-center gap-2"
>
<topic.icon className="w-4 h-4" />
<span>{topic.label}</span>
{topic.id === selectedTopic && (
<span className="ml-auto text-primary"></span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

67
src/pages/primary/DiscussionsPage/ViewToggle.tsx

@ -1,67 +0,0 @@
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ChevronDown, List, Grid3X3 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface ViewToggleProps {
viewMode: 'flat' | 'grouped'
onViewModeChange: (mode: 'flat' | 'grouped') => void
disabled?: boolean
}
export default function ViewToggle({ viewMode, onViewModeChange, disabled = false }: ViewToggleProps) {
const { t } = useTranslation()
const viewOptions = [
{
id: 'flat' as const,
label: t('Flat View'),
icon: List,
description: t('Show all discussions in a single list')
},
{
id: 'grouped' as const,
label: t('Grouped View'),
icon: Grid3X3,
description: t('Group discussions by topic')
}
]
const selectedOption = viewOptions.find(option => option.id === viewMode) || viewOptions[0]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 h-10 px-3 min-w-32"
disabled={disabled}
>
<selectedOption.icon className="w-4 h-4" />
<span className="flex-1 text-left">{selectedOption.label}</span>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{viewOptions.map(option => (
<DropdownMenuItem
key={option.id}
onClick={() => onViewModeChange(option.id)}
className="flex items-start gap-3 p-3"
>
<option.icon className="w-4 h-4 mt-0.5" />
<div className="flex-1">
<div className="font-medium">{option.label}</div>
<div className="text-xs text-muted-foreground mt-1">
{option.description}
</div>
</div>
{option.id === viewMode && (
<span className="text-primary"></span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

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

File diff suppressed because it is too large Load Diff

10
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -25,10 +25,10 @@ const MAX_BOOKMARK_IDS = 250
/** /**
* Spells Discussions uses NoteList subscribeTimeline one live REQ per relay. * Spells Discussions uses NoteList subscribeTimeline one live REQ per relay.
* The same merged list as DiscussionsPages one-shot query would open 80+ sockets and exhaust * An uncapped merged relay list would open 80+ sockets and exhaust subscription slots;
* subscription slots; cap keeps first paint fast. Full coverage remains on /discussions. * cap keeps first paint fast.
*/ */
const DISCUSSION_FAUX_SPELL_MAX_RELAYS = 32 const DISCUSSION_FAUX_SPELL_MAX_RELAYS = 10
/** Without caps, a long NIP-66 read list consumes the whole 32 slots and fast public relays never get a REQ — discussions stay empty while notifications still work (they blend fast reads). */ /** Without caps, a long NIP-66 read list consumes the whole 32 slots and fast public relays never get a REQ — discussions stay empty while notifications still work (they blend fast reads). */
const DISCUSSION_SPELL_READ_CAP = 10 const DISCUSSION_SPELL_READ_CAP = 10
const DISCUSSION_SPELL_WRITE_CAP = 8 const DISCUSSION_SPELL_WRITE_CAP = 8
@ -265,8 +265,8 @@ export function buildMentionsSpellFilter(pubkey: string): Filter {
} }
/** /**
* Relay set for Spells Discussions (kind 11): same merge order as DiscussionsPage, but capped * Relay set for Spells Discussions (kind 11), capped for subscription-based loading
* for subscription-based loading (see DISCUSSION_FAUX_SPELL_MAX_RELAYS). * (see DISCUSSION_FAUX_SPELL_MAX_RELAYS).
*/ */
/** /**
* Deterministic relay pick: each tier (read / write / fav / fast) is normalized + sorted so NostrProvider * Deterministic relay pick: each tier (read / write / fav / fast) is normalized + sorted so NostrProvider

322
src/services/client-cache.service.ts

@ -1,322 +0,0 @@
import { ExtendedKind } from '@/constants'
import { kinds } from 'nostr-tools'
import type { Event as NEvent } from 'nostr-tools'
import logger from '@/lib/logger'
import indexedDb from './indexed-db.service'
import { getProfileFromEvent } from '@/lib/event-metadata'
import type { TProfile, TRelayList } from '@/types'
import { getRelayListFromEvent } from '@/lib/event-metadata'
/** Cache TTLs in milliseconds */
const CACHE_TTLS = {
PROFILE: 30 * 60 * 1000, // 30 minutes
PAYMENT_INFO: 5 * 60 * 1000, // 5 minutes
RELAY_LIST: 15 * 60 * 1000, // 15 minutes
FOLLOW_LIST: 60 * 60 * 1000, // 1 hour
MUTE_LIST: 60 * 60 * 1000, // 1 hour
OTHER_REPLACEABLE: 60 * 60 * 1000 // 1 hour
} as const
/** Cache refresh thresholds - refresh if older than this */
const REFRESH_THRESHOLDS = {
PROFILE: 15 * 60 * 1000, // 15 minutes
PAYMENT_INFO: 2 * 60 * 1000, // 2 minutes
RELAY_LIST: 10 * 60 * 1000, // 10 minutes
FOLLOW_LIST: 30 * 60 * 1000, // 30 minutes
MUTE_LIST: 30 * 60 * 1000, // 30 minutes
OTHER_REPLACEABLE: 30 * 60 * 1000 // 30 minutes
} as const
interface CacheWarmupConfig {
/** Pubkeys to warm up profiles for */
profilePubkeys?: string[]
/** Pubkeys to warm up relay lists for */
relayListPubkeys?: string[]
/** Whether to warm up follow lists */
warmupFollowLists?: boolean
/** Whether to warm up mute lists */
warmupMuteLists?: boolean
}
class ClientCacheService {
private static instance: ClientCacheService
private refreshQueue = new Set<string>() // pubkey:kind strings
private warmingUp = false
private refreshIntervalId: ReturnType<typeof setInterval> | null = null
static getInstance(): ClientCacheService {
if (!ClientCacheService.instance) {
ClientCacheService.instance = new ClientCacheService()
}
return ClientCacheService.instance
}
/**
* Check if a cached replaceable event is stale and needs refresh
*/
isStale(_pubkey: string, kind: number, cachedAt?: number): boolean {
if (!cachedAt) return true
const threshold = this.getRefreshThreshold(kind)
return Date.now() - cachedAt > threshold
}
/**
* Get refresh threshold for a kind
*/
private getRefreshThreshold(kind: number): number {
if (kind === kinds.Metadata) return REFRESH_THRESHOLDS.PROFILE
if (kind === ExtendedKind.PAYMENT_INFO) return REFRESH_THRESHOLDS.PAYMENT_INFO
if (kind === kinds.RelayList) return REFRESH_THRESHOLDS.RELAY_LIST
if (kind === kinds.Contacts) return REFRESH_THRESHOLDS.FOLLOW_LIST
if (kind === kinds.Mutelist) return REFRESH_THRESHOLDS.MUTE_LIST
return REFRESH_THRESHOLDS.OTHER_REPLACEABLE
}
/**
* Get cache TTL for a kind
*/
private getCacheTTL(kind: number): number {
if (kind === kinds.Metadata) return CACHE_TTLS.PROFILE
if (kind === ExtendedKind.PAYMENT_INFO) return CACHE_TTLS.PAYMENT_INFO
if (kind === kinds.RelayList) return CACHE_TTLS.RELAY_LIST
if (kind === kinds.Contacts) return CACHE_TTLS.FOLLOW_LIST
if (kind === kinds.Mutelist) return CACHE_TTLS.MUTE_LIST
return CACHE_TTLS.OTHER_REPLACEABLE
}
/**
* Check if cached event should be invalidated (too old)
*/
shouldInvalidate(kind: number, cachedAt?: number): boolean {
if (!cachedAt) return false
const ttl = this.getCacheTTL(kind)
return Date.now() - cachedAt > ttl
}
/**
* Warm up cache for common data on login/initialization
*/
async warmupCache(config: CacheWarmupConfig, fetchFn: {
fetchProfile: (id: string) => Promise<TProfile | undefined>
fetchRelayList: (pubkey: string) => Promise<TRelayList>
fetchFollowList?: (pubkey: string) => Promise<string[]>
fetchMuteList?: (pubkey: string) => Promise<NEvent | undefined>
fetchDeletionEvents?: (relayUrls: string[], authorPubkey?: string) => Promise<void>
}): Promise<void> {
if (this.warmingUp) {
logger.debug('[CacheService] Already warming up, skipping')
return
}
this.warmingUp = true
logger.info('[CacheService] Starting cache warmup', config)
try {
const promises: Promise<void>[] = []
// Warm up profiles
if (config.profilePubkeys?.length) {
for (const pubkey of config.profilePubkeys.slice(0, 50)) { // Limit to 50
promises.push(
fetchFn.fetchProfile(pubkey)
.then(() => logger.debug('[CacheService] Warmed profile', { pubkey: pubkey.substring(0, 8) }))
.catch(err => logger.warn('[CacheService] Failed to warm profile', { pubkey: pubkey.substring(0, 8), error: err }))
)
}
}
// Warm up relay lists
if (config.relayListPubkeys?.length) {
for (const pubkey of config.relayListPubkeys.slice(0, 20)) { // Limit to 20
promises.push(
fetchFn.fetchRelayList(pubkey)
.then(() => logger.debug('[CacheService] Warmed relay list', { pubkey: pubkey.substring(0, 8) }))
.catch(err => logger.warn('[CacheService] Failed to warm relay list', { pubkey: pubkey.substring(0, 8), error: err }))
)
}
}
// Warm up follow lists
if (config.warmupFollowLists && fetchFn.fetchFollowList) {
const currentUserPubkey = config.profilePubkeys?.[0] // Assume first is current user
if (currentUserPubkey) {
promises.push(
fetchFn.fetchFollowList(currentUserPubkey)
.then(() => logger.debug('[CacheService] Warmed follow list'))
.catch(err => logger.warn('[CacheService] Failed to warm follow list', { error: err }))
)
}
}
// Warm up mute lists
if (config.warmupMuteLists && fetchFn.fetchMuteList) {
const currentUserPubkey = config.profilePubkeys?.[0]
if (currentUserPubkey) {
promises.push(
fetchFn.fetchMuteList(currentUserPubkey)
.then(() => logger.debug('[CacheService] Warmed mute list'))
.catch(err => logger.warn('[CacheService] Failed to warm mute list', { error: err }))
)
}
}
if (fetchFn.fetchDeletionEvents) {
const authorPubkey = config.profilePubkeys?.[0]
fetchFn.fetchDeletionEvents([], authorPubkey).catch((err) =>
logger.warn('[CacheService] Failed to fetch deletion events', { error: err })
)
}
await Promise.allSettled(promises)
logger.info('[CacheService] Cache warmup completed', { count: promises.length })
} finally {
this.warmingUp = false
}
}
/**
* Schedule background refresh for stale cache entries
*/
scheduleRefresh(pubkey: string, kind: number, fetchFn: () => Promise<void>): void {
const key = `${pubkey}:${kind}`
if (this.refreshQueue.has(key)) {
return // Already queued
}
// Check if actually stale by getting the cached timestamp
indexedDb.getReplaceableEventCachedAt(pubkey, kind).then(cachedAt => {
if (cachedAt === undefined) return // Not in cache
// Check if stale using the actual cached timestamp
const isStale = this.isStale(pubkey, kind, cachedAt)
if (isStale) {
this.refreshQueue.add(key)
// Refresh in background (non-blocking)
fetchFn()
.then(() => {
logger.debug('[CacheService] Refreshed cache', { pubkey: pubkey.substring(0, 8), kind })
})
.catch(err => {
logger.warn('[CacheService] Failed to refresh cache', { pubkey: pubkey.substring(0, 8), kind, error: err })
})
.finally(() => {
this.refreshQueue.delete(key)
})
}
}).catch(() => {
// Ignore errors
})
}
/**
* Start periodic cache refresh for stale entries
*/
startPeriodicRefresh(refreshFn: (pubkey: string, kind: number) => Promise<void>): void {
if (this.refreshIntervalId) {
return // Already running
}
logger.info('[CacheService] Starting periodic cache refresh')
this.refreshIntervalId = setInterval(async () => {
try {
// Check for stale profiles (limit to avoid overwhelming)
await this.refreshStaleProfiles(refreshFn)
} catch (error) {
logger.warn('[CacheService] Periodic refresh error', { error })
}
}, 5 * 60 * 1000) // Every 5 minutes
}
/**
* Stop periodic cache refresh
*/
stopPeriodicRefresh(): void {
if (this.refreshIntervalId) {
clearInterval(this.refreshIntervalId)
this.refreshIntervalId = null
logger.info('[CacheService] Stopped periodic cache refresh')
}
}
/**
* Refresh stale profiles (limited batch)
*/
private async refreshStaleProfiles(_refreshFn: (pubkey: string, kind: number) => Promise<void>): Promise<void> {
// This would iterate through cached profiles and refresh stale ones
// For now, this is a placeholder - would need IndexedDB iteration
logger.debug('[CacheService] Checking for stale profiles to refresh')
}
/**
* Get cached profile with fallback - returns cached immediately, refreshes in background if stale
*/
async getProfileWithRefresh(
pubkey: string,
fetchFn: () => Promise<TProfile | undefined>
): Promise<TProfile | undefined> {
// Try cache first
const cached = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
if (cached) {
const profile = getProfileFromEvent(cached)
// Get the timestamp when this was cached
const cachedAt = await indexedDb.getReplaceableEventCachedAt(pubkey, kinds.Metadata)
// If stale, refresh in background
if (this.isStale(pubkey, kinds.Metadata, cachedAt)) {
this.scheduleRefresh(pubkey, kinds.Metadata, async () => {
await fetchFn()
})
}
return profile
}
// Not in cache, fetch now
return await fetchFn()
}
/**
* Get cached relay list with fallback - returns cached immediately, refreshes in background if stale
*/
async getRelayListWithRefresh(
pubkey: string,
fetchFn: () => Promise<TRelayList>
): Promise<TRelayList> {
// Try cache first
const cached = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
if (cached) {
const relayList = getRelayListFromEvent(cached)
// Get the timestamp when this was cached
const cachedAt = await indexedDb.getReplaceableEventCachedAt(pubkey, kinds.RelayList)
// If stale, refresh in background
if (this.isStale(pubkey, kinds.RelayList, cachedAt)) {
this.scheduleRefresh(pubkey, kinds.RelayList, async () => {
await fetchFn()
})
}
return relayList
}
// Not in cache, fetch now
return await fetchFn()
}
/**
* Clear all caches
*/
clearAll(): void {
this.refreshQueue.clear()
logger.info('[CacheService] Cleared all cache refresh queues')
}
}
export const cacheService = ClientCacheService.getInstance()
export default cacheService

55
src/services/transaction.service.ts

@ -1,55 +0,0 @@
import { JUMBLE_API_BASE_URL } from '@/constants'
class TransactionService {
static instance: TransactionService
constructor() {
if (!TransactionService.instance) {
TransactionService.instance = this
}
return TransactionService.instance
}
async createTransaction(
pubkey: string,
amount: number
): Promise<{
transactionId: string
invoiceId: string
}> {
const url = new URL('/v1/transactions', JUMBLE_API_BASE_URL).toString()
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pubkey,
amount,
purpose: 'translation'
})
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error ?? 'Failed to create transaction')
}
return data
}
async checkTransaction(transactionId: string): Promise<{
state: 'pending' | 'failed' | 'settled'
}> {
const url = new URL(`/v1/transactions/${transactionId}/check`, JUMBLE_API_BASE_URL).toString()
const response = await fetch(url, {
method: 'POST'
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error ?? 'Failed to complete transaction')
}
return data
}
}
const instance = new TransactionService()
export default instance

286
test-navigation-manual.js

@ -1,286 +0,0 @@
#!/usr/bin/env node
/**
* Manual Navigation Test
*
* Tests the navigation service without requiring a full test framework.
* This verifies that the refactored navigation system works correctly.
*/
console.log('🧪 Manual Navigation System Test\n')
// Mock the required dependencies
const mockContext = {
setPrimaryNoteView: (component, viewType) => {
console.log(`✅ setPrimaryNoteView called with viewType: ${viewType}`)
}
}
// Mock window.history
global.window = {
history: {
pushState: (state, title, url) => {
console.log(`✅ history.pushState called with URL: ${url}`)
},
back: () => {
console.log(`✅ history.back called`)
}
}
}
// Mock React components (simplified)
const mockComponents = {
NotePage: (props) => `NotePage(${props.id})`,
RelayPage: (props) => `RelayPage(${props.url})`,
ProfilePage: (props) => `ProfilePage(${props.id})`,
SettingsPage: () => 'SettingsPage()',
GeneralSettingsPage: () => 'GeneralSettingsPage()',
RelaySettingsPage: () => 'RelaySettingsPage()',
WalletPage: () => 'WalletPage()',
PostSettingsPage: () => 'PostSettingsPage()',
TranslationPage: () => 'TranslationPage()',
FollowingListPage: (props) => `FollowingListPage(${props.id})`,
MuteListPage: (props) => `MuteListPage(${props.id})`,
OthersRelaySettingsPage: (props) => `OthersRelaySettingsPage(${props.id})`,
NoteListPage: () => 'NoteListPage()'
}
// Mock the navigation service
class MockNavigationService {
constructor(context) {
this.context = context
}
navigateToNote(url) {
const noteId = url.replace('/notes/', '')
console.log(`📝 Navigating to note: ${noteId}`)
this.updateHistoryAndView(url, mockComponents.NotePage({ id: noteId }), 'note')
}
navigateToRelay(url) {
const relayUrl = decodeURIComponent(url.replace('/relays/', ''))
console.log(`🔗 Navigating to relay: ${relayUrl}`)
this.updateHistoryAndView(url, mockComponents.RelayPage({ url: relayUrl }), 'relay')
}
navigateToProfile(url) {
const profileId = url.replace('/users/', '')
console.log(`👤 Navigating to profile: ${profileId}`)
this.updateHistoryAndView(url, mockComponents.ProfilePage({ id: profileId }), 'profile')
}
navigateToHashtag(url) {
console.log(`# Navigating to hashtag page`)
this.updateHistoryAndView(url, mockComponents.NoteListPage(), 'hashtag')
}
navigateToSettings(url) {
if (url === '/settings') {
console.log(` Navigating to main settings`)
this.updateHistoryAndView(url, mockComponents.SettingsPage(), 'settings')
} else if (url.includes('/general')) {
console.log(` Navigating to general settings`)
this.updateHistoryAndView(url, mockComponents.GeneralSettingsPage(), 'settings-sub')
} else if (url.includes('/relays')) {
console.log(` Navigating to relay settings`)
this.updateHistoryAndView(url, mockComponents.RelaySettingsPage(), 'settings-sub')
} else if (url.includes('/wallet')) {
console.log(` Navigating to wallet settings`)
this.updateHistoryAndView(url, mockComponents.WalletPage(), 'settings-sub')
} else if (url.includes('/posts')) {
console.log(` Navigating to post settings`)
this.updateHistoryAndView(url, mockComponents.PostSettingsPage(), 'settings-sub')
} else if (url.includes('/translation')) {
console.log(` Navigating to translation settings`)
this.updateHistoryAndView(url, mockComponents.TranslationPage(), 'settings-sub')
}
}
navigateToFollowingList(url) {
const profileId = url.replace('/users/', '').replace('/following', '')
console.log(`👥 Navigating to following list: ${profileId}`)
this.updateHistoryAndView(url, mockComponents.FollowingListPage({ id: profileId }), 'following')
}
navigateToMuteList(url) {
const profileId = url.replace('/users/', '').replace('/muted', '')
console.log(`🔇 Navigating to mute list: ${profileId}`)
this.updateHistoryAndView(url, mockComponents.MuteListPage({ id: profileId }), 'mute')
}
navigateToOthersRelaySettings(url) {
const profileId = url.replace('/users/', '').replace('/relays', '')
console.log(`🔗 Navigating to others relay settings: ${profileId}`)
this.updateHistoryAndView(url, mockComponents.OthersRelaySettingsPage({ id: profileId }), 'others-relay-settings')
}
getPageTitle(viewType, pathname) {
const titles = {
'settings': 'Settings',
'settings-sub': pathname.includes('/general') ? 'General Settings' :
pathname.includes('/relays') ? 'Relay Settings' :
pathname.includes('/wallet') ? 'Wallet Settings' :
pathname.includes('/posts') ? 'Post Settings' :
pathname.includes('/translation') ? 'Translation Settings' : 'Settings',
'profile': pathname.includes('/following') ? 'Following' :
pathname.includes('/relays') ? 'Relay Settings' : 'Profile',
'hashtag': 'Hashtag',
'relay': 'Relay',
'note': 'Note',
'following': 'Following',
'mute': 'Muted Users',
'others-relay-settings': 'Relay Settings',
'null': 'Page'
}
return titles[viewType] || 'Page'
}
handleBackNavigation(viewType) {
if (viewType === 'settings-sub') {
console.log(` Back navigation: Going to main settings`)
this.navigateToSettings('/settings')
} else {
console.log(` Back navigation: Using browser back`)
global.window.history.back()
}
}
updateHistoryAndView(url, component, viewType) {
global.window.history.pushState(null, '', url)
this.context.setPrimaryNoteView(component, viewType)
}
}
// Test the navigation service
function runTests() {
console.log('🚀 Starting Navigation Service Tests\n')
const service = new MockNavigationService(mockContext)
// Test 1: Note Navigation
console.log('Test 1: Note Navigation')
console.log('─'.repeat(50))
service.navigateToNote('/notes/note123')
console.log(`Page Title: ${service.getPageTitle('note', '/notes/note123')}\n`)
// Test 2: Relay Navigation with URL Encoding
console.log('Test 2: Relay Navigation (URL Encoded)')
console.log('─'.repeat(50))
const encodedRelayUrl = 'wss%3A%2F%2Frelay.example.com%2F'
service.navigateToRelay(`/relays/${encodedRelayUrl}`)
console.log(`Page Title: ${service.getPageTitle('relay', '/relays/wss://relay.example.com')}\n`)
// Test 3: Profile Navigation
console.log('Test 3: Profile Navigation')
console.log('─'.repeat(50))
service.navigateToProfile('/users/npub123')
console.log(`Page Title: ${service.getPageTitle('profile', '/users/npub123')}\n`)
// Test 4: Hashtag Navigation
console.log('Test 4: Hashtag Navigation')
console.log('─'.repeat(50))
service.navigateToHashtag('/notes?t=bitcoin')
console.log(`Page Title: ${service.getPageTitle('hashtag', '/notes?t=bitcoin')}\n`)
// Test 5: Settings Navigation
console.log('Test 5: Settings Navigation')
console.log('─'.repeat(50))
service.navigateToSettings('/settings')
console.log(`Page Title: ${service.getPageTitle('settings', '/settings')}\n`)
// Test 6: Settings Sub-page Navigation
console.log('Test 6: Settings Sub-page Navigation')
console.log('─'.repeat(50))
service.navigateToSettings('/settings/general')
console.log(`Page Title: ${service.getPageTitle('settings-sub', '/settings/general')}\n`)
// Test 7: Following List Navigation
console.log('Test 7: Following List Navigation')
console.log('─'.repeat(50))
service.navigateToFollowingList('/users/npub123/following')
console.log(`Page Title: ${service.getPageTitle('following', '/users/npub123/following')}\n`)
// Test 8: Mute List Navigation
console.log('Test 8: Mute List Navigation')
console.log('─'.repeat(50))
service.navigateToMuteList('/users/npub123/muted')
console.log(`Page Title: ${service.getPageTitle('mute', '/users/npub123/muted')}\n`)
// Test 9: Others Relay Settings Navigation
console.log('Test 9: Others Relay Settings Navigation')
console.log('─'.repeat(50))
service.navigateToOthersRelaySettings('/users/npub123/relays')
console.log(`Page Title: ${service.getPageTitle('others-relay-settings', '/users/npub123/relays')}\n`)
// Test 10: Back Navigation
console.log('Test 10: Back Navigation')
console.log('─'.repeat(50))
service.handleBackNavigation('settings-sub')
service.handleBackNavigation('note')
console.log()
// Test 11: Complete Navigation Flow (Mobile/Desktop Simulation)
console.log('Test 11: Complete Navigation Flow')
console.log('─'.repeat(50))
console.log('Simulating mobile/desktop single-pane navigation...')
// Start with home (no navigation)
console.log('📱 Starting at home page')
// Navigate to note
service.navigateToNote('/notes/note123')
// Navigate to profile from note
service.navigateToProfile('/users/npub123')
// Navigate to following list
service.navigateToFollowingList('/users/npub123/following')
// Navigate to settings
service.navigateToSettings('/settings')
// Navigate to settings sub-page
service.navigateToSettings('/settings/general')
// Navigate to relay
service.navigateToRelay('/relays/wss://relay.example.com')
// Navigate to hashtag
service.navigateToHashtag('/notes?t=bitcoin')
console.log('\n✅ Complete navigation flow successful!')
console.log()
// Test 12: Error Handling
console.log('Test 12: Error Handling')
console.log('─'.repeat(50))
console.log('Testing malformed URLs...')
try {
service.navigateToNote('')
service.navigateToRelay('')
service.navigateToProfile('')
console.log('✅ Error handling works correctly')
} catch (error) {
console.log(`❌ Error handling failed: ${error.message}`)
}
console.log()
console.log('🎉 All Navigation Tests Completed Successfully!')
console.log()
console.log('📱 Mobile and Desktop Verification:')
console.log(' ✅ URL parsing works correctly')
console.log(' ✅ Component creation works properly')
console.log(' ✅ Navigation service handles all view types')
console.log(' ✅ Single-pane navigation flow works')
console.log(' ✅ Back navigation behaves correctly')
console.log(' ✅ Page titles are generated properly')
console.log(' ✅ Error handling works gracefully')
console.log(' ✅ URL encoding/decoding works correctly')
console.log()
console.log('🚀 Navigation system is ready for production!')
}
// Run the tests
runTests()

40
test-navigation.js

@ -1,40 +0,0 @@
#!/usr/bin/env node
/**
* Navigation Test Runner
*
* Runs the navigation service tests to verify single-pane navigation works
* correctly for both mobile and desktop scenarios.
*/
const { execSync } = require('child_process')
const path = require('path')
console.log('🧪 Running Navigation Service Tests...\n')
try {
// Run the tests
const testCommand = 'npm test -- --testPathPattern=navigation.service.test.ts --verbose'
console.log(`Running: ${testCommand}\n`)
execSync(testCommand, {
stdio: 'inherit',
cwd: path.resolve(__dirname)
})
console.log('\n✅ All navigation tests passed!')
console.log('\n📱 Mobile and Desktop Navigation Verification:')
console.log(' ✓ URL parsing works correctly')
console.log(' ✓ Component factory creates proper components')
console.log(' ✓ Navigation service handles all view types')
console.log(' ✓ Single-pane navigation flow works')
console.log(' ✓ Back navigation behaves correctly')
console.log(' ✓ Page titles are generated properly')
console.log(' ✓ Error handling works gracefully')
console.log('\n🎉 Navigation system is ready for production!')
} catch (error) {
console.error('\n❌ Navigation tests failed!')
console.error('Please check the test output above for details.')
process.exit(1)
}
Loading…
Cancel
Save