diff --git a/package-lock.json b/package-lock.json index aedc4574..a890b32c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@getalby/bitcoin-connect-react": "^3.10.0", + "@getalby/lightning-tools": "^6.1.0", "@noble/hashes": "^1.6.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -33,6 +34,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", + "@scure/base": "^2.0.0", "@tailwindcss/typography": "^0.5.16", "@tiptap/core": "^2.12.0", "@tiptap/extension-document": "^2.12.0", @@ -51,7 +53,6 @@ "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.0", "dataloader": "^2.2.3", "dayjs": "^1.11.13", "embla-carousel-react": "^8.6.0", @@ -4814,9 +4815,9 @@ ] }, "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==", + "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/" @@ -4851,6 +4852,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", @@ -4876,15 +4886,6 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6406,22 +6407,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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -9081,15 +9066,6 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", diff --git a/package.json b/package.json index 394dcbaf..d1e0f2e1 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@getalby/bitcoin-connect-react": "^3.10.0", + "@getalby/lightning-tools": "^6.1.0", "@noble/hashes": "^1.6.1", + "@scure/base": "^2.0.0", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.3.3", @@ -66,7 +68,6 @@ "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.0", "dataloader": "^2.2.3", "dayjs": "^1.11.13", "embla-carousel-react": "^8.6.0", diff --git a/src/components/AccountManager/BunkerLogin.tsx b/src/components/AccountManager/BunkerLogin.tsx deleted file mode 100644 index 0cb3c924..00000000 --- a/src/components/AccountManager/BunkerLogin.tsx +++ /dev/null @@ -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(null) - - const handleInputChange = (e: React.ChangeEvent) => { - 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 ( - <> -
- - {errMsg &&
{errMsg}
} -
- - - - ) -} diff --git a/src/components/BottomNavigationBar/AccountButton.tsx b/src/components/BottomNavigationBar/AccountButton.tsx deleted file mode 100644 index 8449667a..00000000 --- a/src/components/BottomNavigationBar/AccountButton.tsx +++ /dev/null @@ -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 ( - { - navigate(pubkey ? 'profile' : 'me') - }} - active={active} - > - {pubkey ? ( - profile ? ( - - - - - - - ) : ( - - ) - ) : ( - - )} - - ) -} diff --git a/src/components/FeedSwitcher/index.tsx b/src/components/FeedSwitcher/index.tsx deleted file mode 100644 index a6812214..00000000 --- a/src/components/FeedSwitcher/index.tsx +++ /dev/null @@ -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 ( -
- {visibleRelays.length > 0 && ( - { - logger.debug('FeedSwitcher: Switching to all-favorites') - switchFeed('all-favorites') - close?.() - }} - > -
-
- -
-
{t('All favorite relays')}
-
-
- )} - - {pubkey && ( - { - if (!pubkey) return - switchFeed('following', { pubkey }) - close?.() - }} - > -
-
- -
-
{t('Following')}
-
-
- )} - - {pubkey && ( - { - if (!pubkey) return - switchFeed('bookmarks', { pubkey }) - close?.() - }} - > -
-
- -
-
{t('Bookmarks')}
-
-
- )} - -
- close?.()} - > - {t('edit')} - -
- {relaySets - .filter((set) => set.relayUrls.length > 0) - .map((set) => ( - { - if (!select) return - switchFeed('relays', { activeRelaySetId: set.id }) - close?.() - }} - /> - ))} - {visibleRelays.map((relay) => ( - { - switchFeed('relay', { relay }) - close?.() - }} - > -
- -
{simplifyUrl(relay)}
-
-
- ))} -
- ) -} - -function FeedSwitcherItem({ - children, - isActive, - onClick, - controls -}: { - children: React.ReactNode - isActive: boolean - onClick: () => void - controls?: React.ReactNode -}) { - return ( -
-
-
{children}
- {controls} -
-
- ) -} diff --git a/src/components/NotificationList/NotificationItem/DiscussionNotification.tsx b/src/components/NotificationList/NotificationItem/DiscussionNotification.tsx deleted file mode 100644 index 92fa624a..00000000 --- a/src/components/NotificationList/NotificationItem/DiscussionNotification.tsx +++ /dev/null @@ -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 ( - } - targetEvent={notification} - showStats={false} - /> - ) -} - diff --git a/src/components/NotificationList/NotificationItem/MentionNotification.tsx b/src/components/NotificationList/NotificationItem/MentionNotification.tsx deleted file mode 100644 index c93211cf..00000000 --- a/src/components/NotificationList/NotificationItem/MentionNotification.tsx +++ /dev/null @@ -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 ( - - ) : parentEventId ? ( - - ) : ( - - ) - } - sender={notification.pubkey} - sentAt={notification.created_at} - targetEvent={notification} - middle={ - notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED && - parentEventId && ( - { - e.stopPropagation() - navigateToNote(toNote(parentEventId)) - }} - /> - ) - } - description={ - isMention ? t('mentioned you in a note') : parentEventId ? '' : t('quoted your note') - } - showStats - /> - ) -} diff --git a/src/components/NotificationList/NotificationItem/Notification.tsx b/src/components/NotificationList/NotificationItem/Notification.tsx deleted file mode 100644 index 31fa9437..00000000 --- a/src/components/NotificationList/NotificationItem/Notification.tsx +++ /dev/null @@ -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 ( -
-
- - {icon} - {middle} - {targetEvent && ( - - )} -
-
- -
-
- ) - } - - return ( -
-
- {icon} - -
-
-
-
- -
{description}
-
-
{rightAction}
-
- {middle} - {targetEvent && ( - - )} - - {showStats && targetEvent && } -
-
- ) -} - -export function NotificationSkeleton() { - const { notificationListStyle } = useUserPreferences() - - if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) { - return ( -
- - -
- ) - } - - return ( -
-
- - -
-
-
- -
-
- -
-
- -
-
-
- ) -} diff --git a/src/components/NotificationList/NotificationItem/PollResponseNotification.tsx b/src/components/NotificationList/NotificationItem/PollResponseNotification.tsx deleted file mode 100644 index 98a117f6..00000000 --- a/src/components/NotificationList/NotificationItem/PollResponseNotification.tsx +++ /dev/null @@ -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 ( - } - sender={notification.pubkey} - sentAt={notification.created_at} - targetEvent={pollEvent} - description={t('voted in your poll')} - /> - ) -} diff --git a/src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx b/src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx deleted file mode 100644 index 6b345f79..00000000 --- a/src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx +++ /dev/null @@ -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 ( - } - sender={notification.pubkey} - sentAt={notification.created_at} - targetEvent={notification} - description={description} - showStats - /> - ) -} diff --git a/src/components/NotificationList/NotificationItem/ReactionNotification.tsx b/src/components/NotificationList/NotificationItem/ReactionNotification.tsx deleted file mode 100644 index 3f495155..00000000 --- a/src/components/NotificationList/NotificationItem/ReactionNotification.tsx +++ /dev/null @@ -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 - } - - 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 ( - {emojiName}} - /> - ) - } - } - return notification.content - }, [notification]) - - if (!event || !eventId) { - return null - } - - return ( - {reaction}} - sender={notification.pubkey} - sentAt={notification.created_at} - targetEvent={event} - description={t('reacted to your note')} - /> - ) -} diff --git a/src/components/NotificationList/NotificationItem/RepostNotification.tsx b/src/components/NotificationList/NotificationItem/RepostNotification.tsx deleted file mode 100644 index c67d6407..00000000 --- a/src/components/NotificationList/NotificationItem/RepostNotification.tsx +++ /dev/null @@ -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 ( - } - sender={notification.pubkey} - sentAt={notification.created_at} - targetEvent={event} - description={t('boosted your note')} - /> - ) -} diff --git a/src/components/NotificationList/NotificationItem/ZapNotification.tsx b/src/components/NotificationList/NotificationItem/ZapNotification.tsx deleted file mode 100644 index 288085ed..00000000 --- a/src/components/NotificationList/NotificationItem/ZapNotification.tsx +++ /dev/null @@ -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 ( - } - sender={senderPubkey} - sentAt={notification.created_at} - targetEvent={event} - middle={ -
- {formatAmount(amount)} {t('sats')} {comment} -
- } - description={event ? t('zapped your note') : t('zapped you')} - /> - ) -} diff --git a/src/components/NotificationList/NotificationItem/index.tsx b/src/components/NotificationList/NotificationItem/index.tsx deleted file mode 100644 index 352b89cd..00000000 --- a/src/components/NotificationList/NotificationItem/index.tsx +++ /dev/null @@ -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 - } - if (notification.kind === kinds.Reaction) { - return - } - if (notification.kind === ExtendedKind.PUBLIC_MESSAGE) { - return - } - if ( - notification.kind === kinds.ShortTextNote || - notification.kind === ExtendedKind.COMMENT || - notification.kind === ExtendedKind.VOICE_COMMENT || - notification.kind === ExtendedKind.POLL - ) { - return - } - if (notification.kind === kinds.Repost) { - return - } - if (notification.kind === kinds.Zap) { - return - } - if (notification.kind === ExtendedKind.POLL_RESPONSE) { - return - } - return null -} diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx deleted file mode 100644 index ef50c151..00000000 --- a/src/components/NotificationList/index.tsx +++ /dev/null @@ -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(undefined) - const [loading, setLoading] = useState(true) - const [notifications, setNotifications] = useState([]) - const [visibleNotifications, setVisibleNotifications] = useState([]) - const [showCount, setShowCount] = useState(SHOW_COUNT) - const [until, setUntil] = useState(dayjs().unix()) - const supportTouch = useMemo(() => isTouchDevice(), []) - const topRef = useRef(null) - const bottomRef = useRef(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([]) - const statsBatchTimeoutRef = useRef(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 - 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 = ( -
- {visibleNotifications.map((notification) => ( - - ))} -
- {until || loading ? ( -
- -
- ) : ( - t('no more notifications') - )} -
-
- ) - - return ( -
-
- {supportTouch ? ( - { - refresh() - await new Promise((resolve) => setTimeout(resolve, 1000)) - }} - pullingContent="" - > - {list} - - ) : ( - list - )} -
- ) - } -) -NotificationList.displayName = 'NotificationList' -export default NotificationList diff --git a/src/components/PaneModeToggle/index.tsx b/src/components/PaneModeToggle/index.tsx deleted file mode 100644 index 8954db34..00000000 --- a/src/components/PaneModeToggle/index.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/src/components/ProfileCard/index.tsx b/src/components/ProfileCard/index.tsx deleted file mode 100644 index b9eadc6f..00000000 --- a/src/components/ProfileCard/index.tsx +++ /dev/null @@ -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 ( -
-
- - -
-
-
{username}
- -
- {about && ( - - )} - -
- ) -} diff --git a/src/components/SimpleNoteFeed/index.tsx b/src/components/SimpleNoteFeed/index.tsx deleted file mode 100644 index 31006069..00000000 --- a/src/components/SimpleNoteFeed/index.tsx +++ /dev/null @@ -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([]) - 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() - 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 ( -
- {customHeader} -
-
- -

{t('loading...')}

-
-
-
- ) - } - - return ( -
- {customHeader} - - - {/* Events list */} - {events.length > 0 ? ( -
- {events.map((event) => ( - - ))} -
- ) : ( -
-
-

{t('no notes found')}

- -
-
- )} -
- ) -}) - -SimpleNoteFeed.displayName = 'SimpleNoteFeed' - -export default SimpleNoteFeed diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx deleted file mode 100644 index 269b40c0..00000000 --- a/src/components/ui/command.tsx +++ /dev/null @@ -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, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -Command.displayName = CommandPrimitive.displayName - -const CommandDialog = ({ - children, - classNames, - ...props -}: DialogProps & { classNames?: { content?: string } }) => { - return ( - - - - Command Menu - Search and select a command - - - {children} - - - - ) -} - -const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
- - -
-)) - -CommandInput.displayName = CommandPrimitive.Input.displayName - -const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { scrollAreaClassName?: string } ->(({ className, scrollAreaClassName, ...props }, ref) => ( - - - -)) - -CommandList.displayName = CommandPrimitive.List.displayName - -const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->((props, ref) => ( - -)) - -CommandEmpty.displayName = CommandPrimitive.Empty.displayName - -const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) - -CommandGroup.displayName = CommandPrimitive.Group.displayName - -const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -CommandSeparator.displayName = CommandPrimitive.Separator.displayName - -const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) - -CommandItem.displayName = CommandPrimitive.Item.displayName - -const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { - return ( - - ) -} -CommandShortcut.displayName = 'CommandShortcut' - -export { - Command, - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, - CommandShortcut -} diff --git a/src/hooks/useContentParser.tsx b/src/hooks/useContentParser.tsx deleted file mode 100644 index 0ebd0508..00000000 --- a/src/hooks/useContentParser.tsx +++ /dev/null @@ -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 -} - -/** - * 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(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(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 = {} -): UseContentParserReturn { - const [parsedContent, setParsedContent] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(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 = {} -) { - const [parsedFields, setParsedFields] = useState>({}) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - - const { autoParse = true, ...parseOptions } = options - - const parse = async () => { - try { - setIsLoading(true) - setError(null) - - const results: Record = {} - - 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 - } -} diff --git a/src/pages/primary/DiscussionsPage/SubtopicFilter.tsx b/src/pages/primary/DiscussionsPage/SubtopicFilter.tsx deleted file mode 100644 index 05c5ad50..00000000 --- a/src/pages/primary/DiscussionsPage/SubtopicFilter.tsx +++ /dev/null @@ -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 ( -
- {t('Filter by')}: - onSubtopicChange(null)} - > - {t('All')} - - {subtopics.map(subtopic => ( - onSubtopicChange(subtopic)} - > - {formatSubtopicLabel(subtopic)} - {selectedSubtopic === subtopic && ( - - )} - - ))} -
- ) -} - diff --git a/src/pages/primary/DiscussionsPage/ThreadCard.tsx b/src/pages/primary/DiscussionsPage/ThreadCard.tsx deleted file mode 100644 index 36a40a70..00000000 --- a/src/pages/primary/DiscussionsPage/ThreadCard.tsx +++ /dev/null @@ -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 ( - - - {isSmallScreen ? ( -
-
-
-
+{upVotes || 0}
-
-{downVotes || 0}
-
-
-

- {title} -

-
-
- - {topicInfo.id} -
- {allTopics.slice(0, 3).map(topic => ( - - - {topic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} - - ))} -
- {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( -
- - - {groupInfo.groupDisplayName} - -
- )} -
-
-
- - -
-
- - {timeAgo} -
- - {/* Last updated */} -
- {t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo} -
-
-
-
- ) : ( -
-
-
-
+{upVotes || 0}
-
-{downVotes || 0}
-
-
-
-

- {title} -

-
-
- - - {topicInfo.label} - - {allTopics.slice(0, 3).map(topic => ( - - - {topic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} - - ))} -
- - {timeAgo} -
- - {/* Last updated */} -
- {t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo} -
-
- {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( -
- - - {groupInfo.groupDisplayName} - -
- )} -
-
-
- - -
-
- )} -
- - -
- {contentPreview} -
-
-
- ) -} diff --git a/src/pages/primary/DiscussionsPage/ThreadSort.tsx b/src/pages/primary/DiscussionsPage/ThreadSort.tsx deleted file mode 100644 index 16611c27..00000000 --- a/src/pages/primary/DiscussionsPage/ThreadSort.tsx +++ /dev/null @@ -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 ( - - - - - - {sortOptions.map(option => ( - onSortChange(option.id)} - className="flex items-center gap-2" - > - - {option.label} - {option.id === selectedSort && ( - βœ“ - )} - - ))} - - - ) -} diff --git a/src/pages/primary/DiscussionsPage/TopicFilter.tsx b/src/pages/primary/DiscussionsPage/TopicFilter.tsx deleted file mode 100644 index ae682610..00000000 --- a/src/pages/primary/DiscussionsPage/TopicFilter.tsx +++ /dev/null @@ -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 ( - - - - - - onTopicChange('all')} - className="flex items-center gap-2" - > - - {t('All Topics')} - {selectedTopic === 'all' && ( - βœ“ - )} - - {groupsOption && ( - onTopicChange('groups')} - className="flex items-center gap-2" - > - - {t('Groups')} - {selectedTopic === 'groups' && ( - βœ“ - )} - - )} - {sortedTopics.map(topic => ( - onTopicChange(topic.id)} - className="flex items-center gap-2" - > - - {topic.label} - {topic.id === selectedTopic && ( - βœ“ - )} - - ))} - - - ) -} diff --git a/src/pages/primary/DiscussionsPage/ViewToggle.tsx b/src/pages/primary/DiscussionsPage/ViewToggle.tsx deleted file mode 100644 index 8ace7fb1..00000000 --- a/src/pages/primary/DiscussionsPage/ViewToggle.tsx +++ /dev/null @@ -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 ( - - - - - - {viewOptions.map(option => ( - onViewModeChange(option.id)} - className="flex items-start gap-3 p-3" - > - -
-
{option.label}
-
- {option.description} -
-
- {option.id === viewMode && ( - βœ“ - )} -
- ))} -
-
- ) -} diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx deleted file mode 100644 index b85a3ea6..00000000 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ /dev/null @@ -1,1265 +0,0 @@ -import { forwardRef, useEffect, useState, useMemo, useCallback, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { RefreshCw, Search } from 'lucide-react' -import { useNostr } from '@/providers/NostrProvider' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useSmartNoteNavigation } from '@/PageManager' -import { toNote } from '@/lib/link' -import { cn } from '@/lib/utils' -import logger from '@/lib/logger' -import { NostrEvent, Event as NostrEventType } from 'nostr-tools' -import { kinds } from 'nostr-tools' -import { normalizeUrl } from '@/lib/url' -import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' -import client from '@/services/client.service' -import { queryService } from '@/services/client.service' -import discussionFeedCache from '@/services/discussion-feed-cache.service' -import { DISCUSSION_TOPICS } from './discussionTopics' -import ThreadCard from './ThreadCard' -import CreateThreadDialog from './CreateThreadDialog' -import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' -import { extractGroupInfo } from '@/lib/discussion-topics' -import type { TPageRef } from '@/types' - -// Simple event map type -type EventMapEntry = { - event: NostrEvent - relaySources: string[] - tTags: string[] - hashtags: string[] - allTopics: string[] - categorizedTopic: string - commentCount: number - lastCommentTime: number - lastVoteTime: number - upVotes: number - downVotes: number - // Group-related fields - groupId: string | null - groupRelay: string | null - groupDisplayName: string | null - isGroupDiscussion: boolean -} - -// Vote counting function - separate and clean -function countVotesForThread(threadId: string, reactions: NostrEvent[], threadAuthor: string): { upVotes: number, downVotes: number, lastVoteTime: number } { - const userVotes = new Map() - let lastVoteTime = 0 - - // Normalize reaction content according to NIP-25 - const normalizeReaction = (content: string): string => { - const normalized = content.trim() - if (normalized === '' || normalized === '+') return '+' - if (normalized === '-') return '-' - if (normalized === '⬆️' || normalized === '↑' || normalized === 'πŸ‘' || normalized === '❀️' || normalized === 'πŸ”₯') return '+' - if (normalized === '⬇️' || normalized === '↓' || normalized === 'πŸ‘Ž' || normalized === 'πŸ’©') return '-' - return 'emoji' - } - - logger.debug('[DiscussionsPage] Counting votes for thread', threadId.substring(0, 8), 'with', reactions.length, 'reactions') - - // Process all reactions for this thread - reactions.forEach(reaction => { - const eTags = reaction.tags.filter(tag => tag[0] === 'e' && tag[1]) - eTags.forEach(tag => { - if (tag[1] === threadId) { - logger.debug('[DiscussionsPage] Found reaction for thread', threadId.substring(0, 8), ':', { - content: reaction.content, - pubkey: reaction.pubkey.substring(0, 8), - isSelf: reaction.pubkey === threadAuthor, - created_at: reaction.created_at - }) - - // Skip self-votes - if (reaction.pubkey === threadAuthor) { - logger.debug('[DiscussionsPage] Skipping self-vote') - return - } - - const normalizedReaction = normalizeReaction(reaction.content) - logger.debug('[DiscussionsPage] Normalized reaction:', normalizedReaction) - - if (normalizedReaction === '+' || normalizedReaction === '-') { - const existingVote = userVotes.get(reaction.pubkey) - // Only keep the newest vote from each user - if (!existingVote || reaction.created_at > existingVote.created_at) { - userVotes.set(reaction.pubkey, { type: normalizedReaction, created_at: reaction.created_at }) - logger.debug('[DiscussionsPage] Added vote:', normalizedReaction, 'from', reaction.pubkey.substring(0, 8)) - } - } - } - }) - }) - - // Count votes - let upVotes = 0 - let downVotes = 0 - userVotes.forEach(({ type, created_at }) => { - if (type === '+') upVotes++ - else if (type === '-') downVotes++ - if (created_at > lastVoteTime) lastVoteTime = created_at - }) - - return { upVotes, downVotes, lastVoteTime } -} - -// Comment counting function - separate and clean -function countCommentsForThread(threadId: string, comments: NostrEvent[], threadAuthor: string): { commentCount: number, lastCommentTime: number } { - let commentCount = 0 - let lastCommentTime = 0 - - comments.forEach(comment => { - const eTags = comment.tags.filter(tag => tag[0] === 'e' && tag[1]) - eTags.forEach(tag => { - if (tag[1] === threadId) { - // Skip self-comments - if (comment.pubkey === threadAuthor) return - - commentCount++ - if (comment.created_at > lastCommentTime) { - lastCommentTime = comment.created_at - } - } - }) - }) - - return { commentCount, lastCommentTime } -} - -// Topic categorization function -function getTopicFromTags(allTopics: string[], predefinedTopicIds: string[], isGroupDiscussion: boolean = false): string { - // If it's a group discussion, categorize as 'groups' - if (isGroupDiscussion) { - return 'groups' - } - - for (const topic of allTopics) { - if (predefinedTopicIds.includes(topic)) { - return topic - } - } - return 'general' -} - -// Normalize topic function -function normalizeTopic(topic: string): string { - return topic.toLowerCase().replace(/\s+/g, '-') -} - -// Search function for threads -async function searchThreads(entries: EventMapEntry[], query: string): Promise { - if (!query.trim()) return entries - - const searchTerm = query.toLowerCase().trim() - - // Search for profiles that match the query - const matchingPubkeys = new Set() - try { - const profiles = await client.searchProfilesFromLocal(searchTerm, 50) - profiles.forEach(profile => { - matchingPubkeys.add(profile.pubkey) - }) - } catch (error) { - logger.debug('[DiscussionsPage] Profile search failed:', error) - } - - return entries.filter(entry => { - const thread = entry.event - - // Search in title (from tags) - const titleTag = thread.tags.find(tag => tag[0] === 'title') - const title = titleTag ? titleTag[1].toLowerCase() : '' - - // Search in content - const content = thread.content.toLowerCase() - - // Search in tags (t-tags and hashtags) - const allTags = [...entry.tTags, ...entry.hashtags].join(' ').toLowerCase() - - // Search in full author npub - const authorNpub = thread.pubkey.toLowerCase() - - // Search in author tag (for readings) - const authorTag = thread.tags.find(tag => tag[0] === 'author') - const author = authorTag ? authorTag[1].toLowerCase() : '' - - // Search in subject tag (for readings) - const subjectTag = thread.tags.find(tag => tag[0] === 'subject') - const subject = subjectTag ? subjectTag[1].toLowerCase() : '' - - // Check if author matches profile search - const authorMatchesProfile = matchingPubkeys.has(thread.pubkey) - - return title.includes(searchTerm) || - content.includes(searchTerm) || - allTags.includes(searchTerm) || - authorNpub.includes(searchTerm) || - author.includes(searchTerm) || - subject.includes(searchTerm) || - authorMatchesProfile - }) -} - -// Dynamic topic analysis -interface DynamicTopic { - id: string - label: string - count: number - isMainTopic: boolean - isSubtopic: boolean - parentTopic?: string -} - -function analyzeDynamicTopics(entries: EventMapEntry[]): { - mainTopics: DynamicTopic[] - subtopics: DynamicTopic[] - allTopics: DynamicTopic[] -} { - const hashtagCounts = new Map() - const groupCounts = new Map() - const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id) - - // Count hashtag frequency - entries.forEach(entry => { - const allTopics = [...entry.tTags, ...entry.hashtags] - allTopics.forEach(topic => { - if (topic && topic !== 'general' && !predefinedTopicIds.includes(topic)) { - hashtagCounts.set(topic, (hashtagCounts.get(topic) || 0) + 1) - } - }) - - // Count group discussions - if (entry.isGroupDiscussion && entry.groupDisplayName) { - groupCounts.set(entry.groupDisplayName, (groupCounts.get(entry.groupDisplayName) || 0) + 1) - } - }) - - const mainTopics: DynamicTopic[] = [] - const subtopics: DynamicTopic[] = [] - - // Create dynamic topics based on frequency - hashtagCounts.forEach((count, hashtag) => { - const topic: DynamicTopic = { - id: hashtag, - label: hashtag.charAt(0).toUpperCase() + hashtag.slice(1).replace(/-/g, ' '), - count, - isMainTopic: count >= 10, - isSubtopic: count >= 3 && count < 10 - } - - if (topic.isMainTopic) { - mainTopics.push(topic) - } else if (topic.isSubtopic) { - subtopics.push(topic) - } - }) - - // Add "Groups" as a pseudo main-topic if we have group discussions - if (groupCounts.size > 0) { - const totalGroupDiscussions = Array.from(groupCounts.values()).reduce((sum, count) => sum + count, 0) - const groupsMainTopic: DynamicTopic = { - id: 'groups', - label: 'Groups', - count: totalGroupDiscussions, - isMainTopic: true, - isSubtopic: false - } - mainTopics.push(groupsMainTopic) - - // Add individual groups as subtopics under "Groups" - groupCounts.forEach((count, groupDisplayName) => { - const groupSubtopic: DynamicTopic = { - id: `groups-${groupDisplayName}`, - label: groupDisplayName, - count, - isMainTopic: false, - isSubtopic: true, - parentTopic: 'groups' - } - subtopics.push(groupSubtopic) - }) - } - - // Sort by count (most popular first) - mainTopics.sort((a, b) => b.count - a.count) - subtopics.sort((a, b) => b.count - a.count) - - const allTopics = [...mainTopics, ...subtopics] - - // Debug logging (commented out to reduce console spam) - // console.log('Dynamic topics analysis:', { - // hashtagCounts: Object.fromEntries(hashtagCounts), - // mainTopics: mainTopics.map(t => ({ id: t.id, count: t.count })), - // subtopics: subtopics.map(t => ({ id: t.id, count: t.count })), - // allTopics: allTopics.map(t => ({ id: t.id, count: t.count, isMainTopic: t.isMainTopic, isSubtopic: t.isSubtopic })) - // }) - - return { mainTopics, subtopics, allTopics } -} - -// Enhanced topic categorization with dynamic topics -function getEnhancedTopicFromTags(allTopics: string[], predefinedTopicIds: string[], dynamicTopics: DynamicTopic[], isGroupDiscussion: boolean = false): string { - // If it's a group discussion, categorize as 'groups' - if (isGroupDiscussion) { - return 'groups' - } - - // First check predefined topics (these are main topics) - for (const topic of allTopics) { - if (predefinedTopicIds.includes(topic)) { - return topic - } - } - - // Then check dynamic main topics - for (const topic of allTopics) { - const dynamicTopic = dynamicTopics.find(dt => dt.id === topic && dt.isMainTopic) - if (dynamicTopic) { - return topic - } - } - - // If no main topic found, return 'general' as the main topic - // The grouping logic will handle subtopics under their main topics - return 'general' -} - -function DiscussionsPageTitlebar() { - const { t } = useTranslation() - - return ( -
-

{t('Discussions')}

-
- ) -} - -const DiscussionsPage = forwardRef(function DiscussionsPage( - { embedded = false }, - ref -) { - const { t } = useTranslation() - const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const { pubkey } = useNostr() - const { navigateToNote } = useSmartNoteNavigation() - - // State - const [allEventMap, setAllEventMap] = useState>(new Map()) - const [eventMap, setEventMap] = useState>(new Map()) - const [timeSpan, setTimeSpan] = useState<'30days' | '90days' | 'all'>('30days') - const [timeSpanCounts, setTimeSpanCounts] = useState<{ '30days': number, '90days': number, 'all': number }>({ '30days': 0, '90days': 0, 'all': 0 }) - const [loading, setLoading] = useState(false) - const [isRefreshing, setIsRefreshing] = useState(false) - const [showCreateDialog, setShowCreateDialog] = useState(false) - const [selectedTopic, setSelectedTopic] = useState('all') - const [searchQuery, setSearchQuery] = useState('') - const [dynamicTopics, setDynamicTopics] = useState<{ - mainTopics: DynamicTopic[] - subtopics: DynamicTopic[] - allTopics: DynamicTopic[] - }>({ mainTopics: [], subtopics: [], allTopics: [] }) - - // Track if we've initialized to prevent re-fetching on re-renders - const hasInitializedRef = useRef(false) - const isFetchingRef = useRef(false) - - // Build comprehensive relay list (same as pins) - 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) - ...(favoriteRelays || []), // User's favorite relays (kind 10012) - ...FAST_READ_RELAY_URLS, // Fast read relays - ...FAST_WRITE_RELAY_URLS // Fast write relays - ] - - // Normalize and deduplicate relay URLs - const normalizedRelays = allRelays - .map(url => normalizeUrl(url)) - .filter((url): url is string => !!url) - - // Filter blocked relays - const finalRelays = normalizedRelays.filter(relay => - !blockedRelays.some(blocked => relay.includes(blocked)) - ) - - logger.debug('[DiscussionsPage] Using', finalRelays.length, 'comprehensive relays') - return Array.from(new Set(finalRelays)) - }, []) // No dependencies - will be called fresh each time from fetchAllEvents - - // Fetch all events - const fetchAllEvents = useCallback(async (forceRefresh = false) => { - if (isFetchingRef.current && !forceRefresh) return - isFetchingRef.current = true - - // Check cache first (unless forcing refresh) - let hasCachedData = false - if (!forceRefresh) { - const cachedData = discussionFeedCache.getCachedDiscussionsList() - if (cachedData) { - logger.debug('[DiscussionsPage] Using cached discussions list:', cachedData.eventMap.size, 'threads') - setAllEventMap(cachedData.eventMap) - setDynamicTopics(cachedData.dynamicTopics) - setLoading(false) // Display cached data immediately - hasCachedData = true - // Still fetch in background to update cache (but don't show loading or refreshing) - } else { - setLoading(true) - setIsRefreshing(true) - } - } else { - setLoading(true) - setIsRefreshing(true) - } - - try { - logger.debug('[DiscussionsPage] Fetching all discussion threads...', forceRefresh ? '(forced refresh)' : '') - - // Get comprehensive relay list - const allRelays = await buildComprehensiveRelayList() - - logger.debug('[DiscussionsPage] Using relays:', allRelays.slice(0, 10), '... (total:', allRelays.length, ')') - - // Step 1: Fetch all discussion threads (kind 11) - const discussionThreads = await queryService.fetchEvents( - allRelays, - [ - { - kinds: [11], // ExtendedKind.DISCUSSION - limit: 500 // Increased from 100 to load more threads per request - } - ], - { firstRelayResultGraceMs: false } - ) - - logger.debug('[DiscussionsPage] Fetched', discussionThreads.length, 'discussion threads') - if (discussionThreads.length > 0) { - logger.debug('[DiscussionsPage] Sample threads:', discussionThreads.slice(0, 3).map(t => ({ - id: t.id.substring(0, 8), - pubkey: t.pubkey.substring(0, 8), - created_at: new Date(t.created_at * 1000).toISOString() - }))) - } - - // Step 2: Get thread IDs for comment/reaction fetching - // Get cached data first to include cached thread IDs in the fetch - // We ALWAYS include cached thread IDs to get updated counts for all threads we know about - const cachedDataBeforeFetch = discussionFeedCache.getCachedDiscussionsList() - const threadIds = discussionThreads.map((thread: NostrEvent) => thread.id) - const allThreadIds = new Set(threadIds) - - // Add cached thread IDs to fetch comments/reactions for all threads we know about - // This ensures we get updated counts for cached threads too, regardless of whether we're refreshing - if (cachedDataBeforeFetch) { - cachedDataBeforeFetch.eventMap.forEach((_entry, threadId) => { - allThreadIds.add(threadId) - }) - } - - const allThreadIdsArray = Array.from(allThreadIds) - - const [comments, reactions] = await Promise.all([ - allThreadIdsArray.length > 0 - ? queryService.fetchEvents( - allRelays, - [ - { - kinds: [1111], // ExtendedKind.COMMENT - '#e': allThreadIdsArray, - limit: 500 // Increased from 100 to load more comments per request - } - ], - { firstRelayResultGraceMs: false } - ) - : Promise.resolve([]), - allThreadIdsArray.length > 0 - ? queryService.fetchEvents( - allRelays, - [ - { - kinds: [kinds.Reaction], - '#e': allThreadIdsArray, - limit: 500 // Increased from 100 to load more reactions per request - } - ], - { firstRelayResultGraceMs: false } - ) - : Promise.resolve([]) - ]) - - logger.debug('[DiscussionsPage] Fetched', comments.length, 'comments and', reactions.length, 'reactions for', allThreadIdsArray.length, 'threads (', threadIds.length, 'new,', (cachedDataBeforeFetch?.eventMap.size || 0), 'cached)') - - // Debug: Log some reaction details - if (reactions.length > 0) { - logger.debug('[DiscussionsPage] Sample reactions:', reactions.slice(0, 3).map(r => ({ - id: r.id.substring(0, 8), - content: r.content, - pubkey: r.pubkey.substring(0, 8), - tags: r.tags.filter(t => t[0] === 'e') - }))) - } - - // Step 3: Build event map with vote and comment counts for newly fetched threads - const newEventMap = new Map() - - discussionThreads.forEach((thread: NostrEvent) => { - const threadId = thread.id - const threadAuthor = thread.pubkey - - // Count votes and comments for this thread - const voteStats = countVotesForThread(threadId, reactions, threadAuthor) - const commentStats = countCommentsForThread(threadId, comments, threadAuthor) - - // Debug: Log vote stats for threads with votes - if (voteStats.upVotes > 0 || voteStats.downVotes > 0) { - logger.debug('[DiscussionsPage] Thread', threadId.substring(0, 8), 'has votes:', voteStats) - } - - // Get relay sources - const eventHints = client.getEventHints(threadId) - const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] - - // Extract group information - const groupInfo = extractGroupInfo(thread, relaySources) - - // Extract topics - const tTagsRaw = thread.tags.filter((tag: string[]) => tag[0] === 't' && tag[1]).map((tag: string[]) => tag[1].toLowerCase()) - const hashtagsRaw = (thread.content.match(/#\w+/g) || []).map((tag: string) => tag.slice(1).toLowerCase()) - const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])] - - // Categorize topic (will be updated after dynamic topics are analyzed) - const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) - const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds, groupInfo.isGroupDiscussion) - - // Normalize topics - const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) - const hashtags = hashtagsRaw.map((tag: string) => normalizeTopic(tag)) - const allTopics = [...new Set([...tTags, ...hashtags])] - - newEventMap.set(threadId, { - event: thread, - relaySources, - tTags, - hashtags, - allTopics, - categorizedTopic, - commentCount: commentStats.commentCount, - lastCommentTime: commentStats.lastCommentTime, - lastVoteTime: voteStats.lastVoteTime, - upVotes: voteStats.upVotes, - downVotes: voteStats.downVotes, - // Group-related fields - groupId: groupInfo.groupId, - groupRelay: groupInfo.groupRelay, - groupDisplayName: groupInfo.groupDisplayName, - isGroupDiscussion: groupInfo.isGroupDiscussion - }) - }) - - logger.debug('[DiscussionsPage] Built event map with', newEventMap.size, 'new threads') - - // Log vote counts for debugging - newEventMap.forEach((entry, threadId) => { - if (entry.upVotes > 0 || entry.downVotes > 0) { - logger.debug('[DiscussionsPage] Thread', threadId.substring(0, 8) + '...', 'has', entry.upVotes, 'upvotes,', entry.downVotes, 'downvotes') - } - }) - - // Start with cached threads (if any) to preserve all threads we've ever seen - // This ensures thread counts and topic counts don't go down when different relays return different subsets - const allThreadsMap = new Map() - - // First, add all cached threads to preserve them - // CRITICAL: Always preserve cached threads, even if they're not in the new fetch - if (cachedDataBeforeFetch) { - logger.debug('[DiscussionsPage] Preserving', cachedDataBeforeFetch.eventMap.size, 'cached threads') - cachedDataBeforeFetch.eventMap.forEach((entry, threadId) => { - allThreadsMap.set(threadId, { ...entry }) // Create a copy to avoid mutations - }) - } - - // Then, add or update with newly fetched threads - // New threads will be added, existing threads will be updated with fresh data - newEventMap.forEach((entry, threadId) => { - allThreadsMap.set(threadId, { ...entry }) // Always use the fresh data from new fetch - }) - - const threadsBeforeCountUpdate = allThreadsMap.size - logger.debug('[DiscussionsPage] Total threads after merge:', threadsBeforeCountUpdate, '(cached:', cachedDataBeforeFetch?.eventMap.size || 0, '+ new:', newEventMap.size, ', overlaps:', (cachedDataBeforeFetch?.eventMap.size || 0) + newEventMap.size - threadsBeforeCountUpdate, ')') - - // Now update comment/vote counts for ALL threads using fresh comments/reactions - // This ensures cached threads get updated counts from the latest fetch - const finalEventMap = new Map() - allThreadsMap.forEach((entry, threadId) => { - const thread = entry.event - const threadAuthor = thread.pubkey - - // Count votes and comments for this thread (using all fetched comments/reactions) - const voteStats = countVotesForThread(threadId, reactions, threadAuthor) - const commentStats = countCommentsForThread(threadId, comments, threadAuthor) - - // Update the entry with latest counts, but preserve all other data - finalEventMap.set(threadId, { - ...entry, - commentCount: commentStats.commentCount, - lastCommentTime: commentStats.lastCommentTime, - lastVoteTime: voteStats.lastVoteTime, - upVotes: voteStats.upVotes, - downVotes: voteStats.downVotes - }) - }) - - logger.debug('[DiscussionsPage] Final event map has', finalEventMap.size, 'threads after count update') - - // Analyze dynamic topics from ALL threads (cached + new) - const dynamicTopicsAnalysis = analyzeDynamicTopics(Array.from(finalEventMap.values())) - - // Update event map with enhanced topic categorization for all threads - const categorizedEventMap = new Map() - finalEventMap.forEach((entry, threadId) => { - const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) - const enhancedTopic = getEnhancedTopicFromTags(entry.allTopics, predefinedTopicIds, dynamicTopicsAnalysis.allTopics, entry.isGroupDiscussion) - - categorizedEventMap.set(threadId, { - ...entry, - categorizedTopic: enhancedTopic - }) - }) - - logger.debug('[DiscussionsPage] Categorized event map has', categorizedEventMap.size, 'threads') - - // Store final merged and categorized event map in cache - // IMPORTANT: We've already manually merged all cached + new threads above - // So categorizedEventMap contains ALL threads we want to preserve - // We store with merge=false because we've already done the merge manually - // This ensures we don't lose threads due to the cache service's merge logic - const expectedThreadCount = categorizedEventMap.size - discussionFeedCache.setCachedDiscussionsList(categorizedEventMap, dynamicTopicsAnalysis, false) - - // Verify the cache has all our threads (immediately after storing) - const cachedAfterStore = discussionFeedCache.getCachedDiscussionsList() - if (cachedAfterStore) { - const actualThreadCount = cachedAfterStore.eventMap.size - logger.debug('[DiscussionsPage] Cache verification - stored:', expectedThreadCount, 'threads, cache has:', actualThreadCount, 'threads') - if (actualThreadCount !== expectedThreadCount) { - logger.error('[DiscussionsPage] ERROR: Thread count mismatch! Expected', expectedThreadCount, 'but cache has', actualThreadCount) - // If we lost threads, try to recover by storing again with the categorized map - // This shouldn't happen, but if it does, at least log it - } - } else { - logger.error('[DiscussionsPage] ERROR: Cache returned null after storing!') - } - - // Always update state with the merged and categorized event map - // This ensures we show all threads we've ever seen, with updated counts - setAllEventMap(categorizedEventMap) - setDynamicTopics(dynamicTopicsAnalysis) - - logger.debug('[DiscussionsPage] Updated UI with', categorizedEventMap.size, 'threads (merged from cache and new fetch)') - - } catch (error) { - // Get cached data for error logging (if available) - const cachedDataForError = discussionFeedCache.getCachedDiscussionsList() - logger.error('[DiscussionsPage] Error fetching events:', error, { - hasCachedData, - cachedThreadCount: cachedDataForError?.eventMap.size || 0 - }) - // If we had cached data and fetch failed, at least we have something to show - if (!hasCachedData) { - setLoading(false) - } - // Log specific relay errors if available - if (error instanceof Error && error.message) { - logger.warn('[DiscussionsPage] Fetch error details:', error.message) - } - } finally { - if (!hasCachedData || forceRefresh) { - setLoading(false) - } - setIsRefreshing(false) - isFetchingRef.current = false - } - }, [buildComprehensiveRelayList]) // Only depend on buildComprehensiveRelayList - - // Calculate time span counts - const calculateTimeSpanCounts = useCallback(() => { - const now = Date.now() - const thirtyDaysAgo = now - (30 * 24 * 60 * 60 * 1000) - const ninetyDaysAgo = now - (90 * 24 * 60 * 60 * 1000) - - let count30 = 0 - let count90 = 0 - let countAll = 0 - - allEventMap.forEach((entry) => { - const threadTime = entry.event.created_at * 1000 - const lastCommentTime = entry.lastCommentTime > 0 ? entry.lastCommentTime * 1000 : 0 - const lastVoteTime = entry.lastVoteTime > 0 ? entry.lastVoteTime * 1000 : 0 - - // For threads without comments/votes, only use thread creation time - const mostRecentActivity = Math.max( - threadTime, - lastCommentTime, - lastVoteTime - ) - - if (mostRecentActivity > thirtyDaysAgo) count30++ - if (mostRecentActivity > ninetyDaysAgo) count90++ - countAll++ - }) - - setTimeSpanCounts({ '30days': count30, '90days': count90, 'all': countAll }) - }, [allEventMap]) - - // Filter event map for display - const filterEventMapForDisplay = useCallback(() => { - const now = Date.now() - const timeSpanAgo = timeSpan === '30days' ? now - (30 * 24 * 60 * 60 * 1000) : - timeSpan === '90days' ? now - (90 * 24 * 60 * 60 * 1000) : 0 - - const filteredMap = new Map() - - allEventMap.forEach((entry) => { - // Filter by time span - let passesTimeFilter = false - if (timeSpan === 'all') { - passesTimeFilter = true - } else { - const threadTime = entry.event.created_at * 1000 - const lastCommentTime = entry.lastCommentTime > 0 ? entry.lastCommentTime * 1000 : 0 - const lastVoteTime = entry.lastVoteTime > 0 ? entry.lastVoteTime * 1000 : 0 - - const mostRecentActivity = Math.max( - threadTime, - lastCommentTime, - lastVoteTime - ) - - passesTimeFilter = mostRecentActivity > timeSpanAgo - } - - // Filter by topic (including group filtering) - let passesTopicFilter = false - if (selectedTopic === 'all') { - passesTopicFilter = true - } else if (selectedTopic === 'groups') { - // Show all group discussions when "Groups" main topic is selected - passesTopicFilter = entry.isGroupDiscussion - } else if (selectedTopic.startsWith('groups-')) { - // Show specific group when group subtopic is selected - const groupDisplayName = selectedTopic.replace('groups-', '') - passesTopicFilter = entry.isGroupDiscussion && entry.groupDisplayName === groupDisplayName - } else { - // Regular topic filtering - passesTopicFilter = entry.categorizedTopic === selectedTopic - } - - if (passesTimeFilter && passesTopicFilter) { - filteredMap.set(entry.event.id, entry) - } - }) - - setEventMap(filteredMap) - }, [allEventMap, timeSpan, selectedTopic, searchQuery]) - - // Effects - useEffect(() => { - // Only initialize once - if (hasInitializedRef.current) { - logger.debug('[DiscussionsPage] Already initialized, skipping fetch') - return - } - - hasInitializedRef.current = true - fetchAllEvents(false) // Don't force refresh on mount - use cache if available - }, [fetchAllEvents]) - - useEffect(() => { - if (allEventMap.size > 0) { - calculateTimeSpanCounts() - } - }, [allEventMap]) // Run when allEventMap changes - - useEffect(() => { - if (allEventMap.size > 0) { - filterEventMapForDisplay() - } - }, [allEventMap, timeSpan, selectedTopic]) // Run when allEventMap, timeSpan, or selectedTopic changes - - // Listen for state requests and restoration from PageManager - useEffect(() => { - const handleStateRequest = () => { - window.dispatchEvent(new CustomEvent('discussionsStateResponse', { - detail: { selectedTopic, timeSpan } - })) - } - - const handleRestore = (e: CustomEvent<{ page: string, discussionsState?: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } }>) => { - if ( - e.detail.discussionsState && - (e.detail.page === 'discussions' || e.detail.page === 'spells') - ) { - setSelectedTopic(e.detail.discussionsState.selectedTopic) - setTimeSpan(e.detail.discussionsState.timeSpan) - } - } - - window.addEventListener('requestDiscussionsState', handleStateRequest as EventListener) - window.addEventListener('restoreDiscussionsState', handleRestore as EventListener) - return () => { - window.removeEventListener('requestDiscussionsState', handleStateRequest as EventListener) - window.removeEventListener('restoreDiscussionsState', handleRestore as EventListener) - } - }, [selectedTopic, timeSpan]) - - // Get available topics sorted by most recent activity (including dynamic topics) - // Topic counts are calculated based on the current time span filter - const availableTopics = useMemo(() => { - const topicMap = new Map() - - // Calculate time span filter - const now = Date.now() - const timeSpanAgo = timeSpan === '30days' ? now - (30 * 24 * 60 * 60 * 1000) : - timeSpan === '90days' ? now - (90 * 24 * 60 * 60 * 1000) : 0 - - allEventMap.forEach((entry) => { - // Filter by time span - only count topics for threads that match the time filter - let passesTimeFilter = false - if (timeSpan === 'all') { - passesTimeFilter = true - } else { - const threadTime = entry.event.created_at * 1000 - const lastCommentTime = entry.lastCommentTime > 0 ? entry.lastCommentTime * 1000 : 0 - const lastVoteTime = entry.lastVoteTime > 0 ? entry.lastVoteTime * 1000 : 0 - - const mostRecentActivity = Math.max( - threadTime, - lastCommentTime, - lastVoteTime - ) - - passesTimeFilter = mostRecentActivity > timeSpanAgo - } - - // Only count topics for threads that pass the time filter - if (!passesTimeFilter) return - - const topic = entry.categorizedTopic - const lastActivity = Math.max( - entry.event.created_at * 1000, - entry.lastCommentTime > 0 ? entry.lastCommentTime * 1000 : 0, - entry.lastVoteTime > 0 ? entry.lastVoteTime * 1000 : 0 - ) - - if (!topicMap.has(topic)) { - const dynamicTopic = dynamicTopics.allTopics.find(dt => dt.id === topic) - topicMap.set(topic, { - count: 0, - lastActivity: 0, - isDynamic: !!dynamicTopic, - isMainTopic: dynamicTopic?.isMainTopic || false, - isSubtopic: dynamicTopic?.isSubtopic || false - }) - } - - const current = topicMap.get(topic)! - current.count++ - current.lastActivity = Math.max(current.lastActivity, lastActivity) - }) - - // Convert to array and sort by most recent activity - return Array.from(topicMap.entries()) - .map(([topic, data]) => ({ topic, ...data })) - .sort((a, b) => b.lastActivity - a.lastActivity) - }, [allEventMap, dynamicTopics, timeSpan]) // Include timeSpan in dependencies - - // State for search results - const [searchedEntries, setSearchedEntries] = useState([]) - const [isSearching, setIsSearching] = useState(false) - - // Handle search with debouncing - useEffect(() => { - const performSearch = async () => { - if (!searchQuery.trim()) { - setSearchedEntries(Array.from(eventMap.values())) - return - } - - setIsSearching(true) - try { - const allEntries = Array.from(eventMap.values()) - const results = await searchThreads(allEntries, searchQuery) - setSearchedEntries(results) - } catch (error) { - logger.error('[DiscussionsPage] Search failed:', error) - setSearchedEntries(Array.from(eventMap.values())) - } finally { - setIsSearching(false) - } - } - - const timeoutId = setTimeout(performSearch, 300) // 300ms debounce - return () => clearTimeout(timeoutId) - }, [eventMap, searchQuery]) - - // Group events by topic with hierarchy (main topics and subtopics) - const groupedEvents = useMemo(() => { - const mainTopicGroups = new Map - }>() - - searchedEntries.forEach((entry) => { - // Check if this entry has any dynamic subtopics - const entrySubtopics = entry.allTopics.filter(topic => { - const dynamicTopic = dynamicTopics.allTopics.find(dt => dt.id === topic && dt.isSubtopic) - return !!dynamicTopic - }) - - if (entrySubtopics.length > 0) { - // This entry has subtopics - group under the main topic with the subtopic - const mainTopic = entry.categorizedTopic - const subtopic = entrySubtopics[0] - - // Initialize main topic group if it doesn't exist - if (!mainTopicGroups.has(mainTopic)) { - mainTopicGroups.set(mainTopic, { - entries: [], - subtopics: new Map() - }) - } - - const group = mainTopicGroups.get(mainTopic)! - - // Add to subtopic group - if (!group.subtopics.has(subtopic)) { - group.subtopics.set(subtopic, []) - } - group.subtopics.get(subtopic)!.push(entry) - } else { - // No subtopic, add to main topic - const mainTopic = entry.categorizedTopic - - // Initialize main topic group if it doesn't exist - if (!mainTopicGroups.has(mainTopic)) { - mainTopicGroups.set(mainTopic, { - entries: [], - subtopics: new Map() - }) - } - - const group = mainTopicGroups.get(mainTopic)! - group.entries.push(entry) - } - }) - - // Sort threads within each group and subtopic by newest-first - mainTopicGroups.forEach((group) => { - const sortEntries = (entries: EventMapEntry[]) => { - entries.sort((a, b) => { - const aActivity = Math.max( - a.event.created_at * 1000, - a.lastCommentTime > 0 ? a.lastCommentTime * 1000 : 0, - a.lastVoteTime > 0 ? a.lastVoteTime * 1000 : 0 - ) - const bActivity = Math.max( - b.event.created_at * 1000, - b.lastCommentTime > 0 ? b.lastCommentTime * 1000 : 0, - b.lastVoteTime > 0 ? b.lastVoteTime * 1000 : 0 - ) - return bActivity - aActivity // Newest first - }) - } - - sortEntries(group.entries) - group.subtopics.forEach((entries) => sortEntries(entries)) - }) - - // Sort groups by most recent activity (newest first) - const sortedGroups = new Map }>() - - const sortedEntries = Array.from(mainTopicGroups.entries()).sort(([, aGroup], [, bGroup]) => { - const aEntries = aGroup.entries - const bEntries = bGroup.entries - - if (aEntries.length === 0 && bEntries.length === 0) return 0 - if (aEntries.length === 0) return 1 - if (bEntries.length === 0) return -1 - - const aMostRecent = Math.max( - aEntries[0].event.created_at * 1000, - aEntries[0].lastCommentTime > 0 ? aEntries[0].lastCommentTime * 1000 : 0, - aEntries[0].lastVoteTime > 0 ? aEntries[0].lastVoteTime * 1000 : 0 - ) - const bMostRecent = Math.max( - bEntries[0].event.created_at * 1000, - bEntries[0].lastCommentTime > 0 ? bEntries[0].lastCommentTime * 1000 : 0, - bEntries[0].lastVoteTime > 0 ? bEntries[0].lastVoteTime * 1000 : 0 - ) - - return bMostRecent - aMostRecent // Newest first - }) - - sortedEntries.forEach(([topic, group]) => { - sortedGroups.set(topic, group) - }) - - return sortedGroups - }, [searchedEntries, dynamicTopics]) - - // Handle refresh - const handleRefresh = () => { - fetchAllEvents(true) // Force refresh when user clicks refresh button - } - - // Handle create thread - const handleCreateThread = (publishedEvent?: NostrEventType) => { - if (!publishedEvent) return - - // Add to event map immediately - const threadId = publishedEvent.id - const tTagsRaw = publishedEvent.tags.filter((tag: string[]) => tag[0] === 't' && tag[1]).map((tag: string[]) => tag[1].toLowerCase()) - const hashtagsRaw = (publishedEvent.content.match(/#\w+/g) || []).map((tag: string) => tag.slice(1).toLowerCase()) - const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])] - const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) - const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) - const hashtags = hashtagsRaw.map((tag: string) => normalizeTopic(tag)) - const allTopics = [...new Set([...tTags, ...hashtags])] - const eventHints = client.getEventHints(threadId) - const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] - - // Extract group information - const groupInfo = extractGroupInfo(publishedEvent, relaySources) - const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds, groupInfo.isGroupDiscussion) - - const newEntry: EventMapEntry = { - event: publishedEvent, - relaySources, - tTags, - hashtags, - allTopics, - categorizedTopic, - commentCount: 0, - lastCommentTime: 0, - lastVoteTime: 0, - upVotes: 0, - downVotes: 0, - // Group-related fields - groupId: groupInfo.groupId, - groupRelay: groupInfo.groupRelay, - groupDisplayName: groupInfo.groupDisplayName, - isGroupDiscussion: groupInfo.isGroupDiscussion - } - - setAllEventMap(prev => new Map(prev).set(threadId, newEntry)) - - // Close the dialog - setShowCreateDialog(false) - } - - // Handle close dialog - const handleCloseDialog = () => { - setShowCreateDialog(false) - } - - // Handle thread click - const handleThreadClick = (threadId: string) => { - navigateToNote(toNote(threadId)) - } - - const mainContent = ( - <> -
-
- -
- - {/* Search Bar */} -
- {isSearching ? ( - - ) : ( - - )} - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
- - {/* Filters - Stack on mobile, row on desktop */} -
- {/* Topic Selection Dropdown */} - - - {/* Time Span Dropdown */} - - - {/* Refresh Button */} - -
-
- - {/* Content */} -
- {loading ? ( -
{t('Loading...')}
- ) : isSearching ? ( -
{t('Searching...')}
- ) : ( -
- {Array.from(groupedEvents.entries()).map(([mainTopic, group]) => { - const topicInfo = availableTopics.find(t => t.topic === mainTopic) - const isDynamicMain = topicInfo?.isDynamic && topicInfo?.isMainTopic - const isGroupsTopic = mainTopic === 'groups' - - return ( -
- {/* Main Topic Header */} -

- - {isGroupsTopic && πŸ‘₯} - {isDynamicMain && !isGroupsTopic && πŸ”₯} - {mainTopic} ({group.entries.length + Array.from(group.subtopics.values()).reduce((sum, events) => sum + events.length, 0)} {group.entries.length + Array.from(group.subtopics.values()).reduce((sum, events) => sum + events.length, 0) === 1 ? t('thread') : t('threads')}) - - {isDynamicMain && Main Topic} -

- - {/* Main Topic Threads */} - {group.entries.length > 0 && ( -
- {group.entries.map((entry) => ( - handleThreadClick(entry.event.id)} - /> - ))} -
- )} - - {/* Subtopic Groups */} - {group.subtopics.size > 0 && ( -
- {Array.from(group.subtopics.entries()).map(([subtopic, subtopicEvents]) => { - const subtopicInfo = availableTopics.find(t => t.topic === subtopic) - const isSubtopicDynamic = subtopicInfo?.isDynamic && subtopicInfo?.isSubtopic - - return ( -
-

- - {isSubtopicDynamic && πŸ“Œ} - {subtopic} ({subtopicEvents.length} {subtopicEvents.length === 1 ? t('thread') : t('threads')}) - - {isSubtopicDynamic && Subtopic} -

-
- {subtopicEvents.map((entry) => ( - handleThreadClick(entry.event.id)} - /> - ))} -
-
- ) - })} -
- )} -
- ) - })} -
- )} -
- - {/* Create Thread Dialog */} - {showCreateDialog && ( - - )} - - ) - - if (embedded) { - return ( -
{mainContent}
- ) - } - - return ( - } - displayScrollToTopButton - > - {mainContent} - - ) -}) - -DiscussionsPage.displayName = 'DiscussionsPage' - -export default DiscussionsPage diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 0e76846b..e9215d5f 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/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. - * The same merged list as DiscussionsPage’s one-shot query would open 80+ sockets and exhaust - * subscription slots; cap keeps first paint fast. Full coverage remains on /discussions. + * An uncapped merged relay list would open 80+ sockets and exhaust subscription slots; + * 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). */ const DISCUSSION_SPELL_READ_CAP = 10 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 - * for subscription-based loading (see DISCUSSION_FAUX_SPELL_MAX_RELAYS). + * Relay set for Spells β€œDiscussions” (kind 11), capped for subscription-based loading + * (see DISCUSSION_FAUX_SPELL_MAX_RELAYS). */ /** * Deterministic relay pick: each tier (read / write / fav / fast) is normalized + sorted so NostrProvider diff --git a/src/services/client-cache.service.ts b/src/services/client-cache.service.ts deleted file mode 100644 index 1b80a510..00000000 --- a/src/services/client-cache.service.ts +++ /dev/null @@ -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() // pubkey:kind strings - private warmingUp = false - private refreshIntervalId: ReturnType | 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 - fetchRelayList: (pubkey: string) => Promise - fetchFollowList?: (pubkey: string) => Promise - fetchMuteList?: (pubkey: string) => Promise - fetchDeletionEvents?: (relayUrls: string[], authorPubkey?: string) => Promise - }): Promise { - 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[] = [] - - // 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 { - 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 { - 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): Promise { - // 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 - ): Promise { - // 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 - ): Promise { - // 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 diff --git a/src/services/transaction.service.ts b/src/services/transaction.service.ts deleted file mode 100644 index 78f3de39..00000000 --- a/src/services/transaction.service.ts +++ /dev/null @@ -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 diff --git a/test-navigation-manual.js b/test-navigation-manual.js deleted file mode 100644 index 7094ac2d..00000000 --- a/test-navigation-manual.js +++ /dev/null @@ -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() diff --git a/test-navigation.js b/test-navigation.js deleted file mode 100755 index 2c148853..00000000 --- a/test-navigation.js +++ /dev/null @@ -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) -}