From a8c5117cc7fcf46a5b14da89f0fa846fa1bac8bc Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 31 Mar 2026 08:21:38 +0200 Subject: [PATCH] add interest list --- src/PageManager.tsx | 28 ++- src/contexts/primary-note-view-context.tsx | 1 + src/i18n/locales/en.ts | 13 +- src/lib/link.ts | 1 + .../primary/SpellsPage/fauxSpellFeeds.ts | 32 ++- .../secondary/InterestListPage/index.tsx | 191 ++++++++++++++++++ .../PersonalListsSettingsPage/index.tsx | 37 +++- src/routes.tsx | 2 + src/services/navigation.service.ts | 2 + 9 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 src/pages/secondary/InterestListPage/index.tsx diff --git a/src/PageManager.tsx b/src/PageManager.tsx index be8c50f2..bf382aea 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -91,6 +91,7 @@ const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/Follow const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage')) const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage')) const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage')) +const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage')) const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage')) const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage')) @@ -777,6 +778,26 @@ export function useSmartPinListNavigation() { return { navigateToPinList } } +export function useSmartInterestListNavigation() { + const { setPrimaryNoteView } = usePrimaryNoteView() + const { push: pushSecondaryPage } = useSecondaryPage() + const { isSmallScreen } = useScreenSize() + + const navigateToInterestList = (url: string) => { + if (isSmallScreen) { + window.history.pushState(null, '', url) + setPrimaryNoteView( + suspensePrimaryPage(), + 'interests' + ) + } else { + pushSecondaryPage(url) + } + } + + return { navigateToInterestList } +} + // Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop export function useSmartOthersRelaySettingsNavigation() { const { setPrimaryNoteView } = usePrimaryNoteView() @@ -1723,7 +1744,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { navigatePrimaryPage('settings') return } - if (primaryViewType === 'bookmarks' || primaryViewType === 'pins' || primaryViewType === 'mute') { + if ( + primaryViewType === 'bookmarks' || + primaryViewType === 'pins' || + primaryViewType === 'interests' || + primaryViewType === 'mute' + ) { setPrimaryNoteView(null) return } diff --git a/src/contexts/primary-note-view-context.tsx b/src/contexts/primary-note-view-context.tsx index 17db10b3..417dd086 100644 --- a/src/contexts/primary-note-view-context.tsx +++ b/src/contexts/primary-note-view-context.tsx @@ -11,6 +11,7 @@ export type TPrimaryOverlayViewType = | 'mute' | 'bookmarks' | 'pins' + | 'interests' | 'others-relay-settings' export type PrimaryNoteViewContextValue = { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 421924e5..a0d877ac 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1580,11 +1580,22 @@ export default { 'Follow sets': 'Follow sets', 'Personal Lists': 'Personal Lists', 'Personal lists hub intro': - 'Open mute list, following, bookmarks list, or pinned notes on their own pages (like mute and following). Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.', + 'Open mute list, following, bookmarks list, pinned notes, or your interest topics (kind 10015) on their own pages (like mute and following). Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.', 'Mute list': 'Mute list', 'Following list': 'Following list', 'Bookmarks list': 'Bookmarks list', 'Pinned notes list': 'Pinned notes list', + 'Interests list': 'Interests list', + 'Interests list section subtitle': + 'Topics you follow for hashtag feeds and the Interests spell. Stored on Nostr as kind 10015 (`t` tags).', + 'Interest topic placeholder': 'topic or #hashtag', + 'Interest list add topic': 'Add topic', + 'Interest topic invalid': 'Enter a valid topic (letters, numbers, hyphens, underscores).', + 'No interest topics in list': 'No subscribed topics yet. Add one above or subscribe from a hashtag page.', + "username's interest topics": "{{username}}'s interest topics", + 'Remove from interest list': 'Remove from interest list', + 'Personal lists interests spell hint': 'For a combined feed of all subscribed topics, use the', + 'Interests spell': 'Interests spell', 'Personal lists bookmarks spell hint': 'For a note feed from NIP-51 bookmarks, use the', 'Bookmarks spell': 'Bookmarks spell', diff --git a/src/lib/link.ts b/src/lib/link.ts index 9d684273..795ba72c 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -82,6 +82,7 @@ export const toMuteList = () => '/mutes' export const toBookmarksList = () => '/bookmarks' export const toPinsList = () => '/pins' +export const toInterestsList = () => '/interests' export const toSpells = () => '/spells' export const toChachiChat = (relay: string, d: string) => { diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index e8dd0739..29ac38d2 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -63,10 +63,15 @@ export const NOTIFICATION_SPELL_KINDS = RENDERABLE_NOTE_KINDS_SORTED export const NOTIFICATION_SPELL_LOADING_SAFETY_MS = 90_000 /** - * Max distinct `t` tag values in one filter (very long `#t` arrays can hit relay limits). + * Max base topics from the interest list. Each base topic expands to singular+plural variants. */ const INTERESTS_MAX_TOPICS = 80 +/** + * Max distinct `t` tag values in one filter after singular/plural expansion. + */ +const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 2 + /** * Put {@link READ_ONLY_RELAY_URLS} (e.g. aggr) **first**, then curated relays. Faux spells cap URL count * ({@link FAUX_SPELL_MAX_RELAYS}); appending read-only at the end dropped mirrors whenever inbox+favorites @@ -157,9 +162,20 @@ export function buildCalendarSpellFilter(): Filter { } } +function pluralizeTopic(topic: string): string { + if (!topic) return topic + if (topic.endsWith('y') && topic.length > 1 && !/[aeiou]y$/i.test(topic)) { + return `${topic.slice(0, -1)}ies` + } + if (/(s|x|z|ch|sh)$/i.test(topic)) { + return `${topic}es` + } + return `${topic}s` +} + /** * One subrequest for all interests: NIP-01 treats multiple `#t` values as OR (any topic matches). - * Same relay set as before, but a single timeline shard instead of one per hashtag. + * Expand every topic to singular+plural so feeds match either spelling on relays. */ export function buildInterestsSubRequests( relayUrls: string[], @@ -167,9 +183,19 @@ export function buildInterestsSubRequests( kindsList: number[] = DEFAULT_FEED_SHOW_KINDS ): TFeedSubRequest[] { if (!relayUrls.length || !rawTopics.length || !kindsList.length) return [] - const topics = Array.from( + const baseTopics = Array.from( new Set(rawTopics.map((t) => normalizeTopic(t)).filter((t) => t.length > 0)) ).slice(0, INTERESTS_MAX_TOPICS) + if (!baseTopics.length) return [] + const topics = Array.from( + new Set( + baseTopics.flatMap((topic) => { + const singular = normalizeTopic(topic) + const plural = pluralizeTopic(singular) + return [singular, plural] + }) + ) + ).slice(0, INTERESTS_MAX_TOPIC_TAG_VALUES) if (!topics.length) return [] return [ { diff --git a/src/pages/secondary/InterestListPage/index.tsx b/src/pages/secondary/InterestListPage/index.tsx new file mode 100644 index 00000000..d1bfaf63 --- /dev/null +++ b/src/pages/secondary/InterestListPage/index.tsx @@ -0,0 +1,191 @@ +import JsonViewDialog from '@/components/JsonViewDialog' +import { RefreshButton } from '@/components/RefreshButton' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' +import { normalizeTopic } from '@/lib/discussion-topics' +import { toNoteList } from '@/lib/link' +import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' +import { cn } from '@/lib/utils' +import { useSmartHashtagNavigation } from '@/PageManager' +import { useInterestList } from '@/providers/InterestListProvider' +import { useNostr } from '@/providers/NostrProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import client from '@/services/client.service' +import { Code, MoreVertical, Trash2 } from 'lucide-react' +import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import NotFoundPage from '../NotFoundPage' + +const INTEREST_LIST_KIND = 10015 + +const InterestListPage = forwardRef( + ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { + const { t } = useTranslation() + const { registerPrimaryPanelRefresh } = usePrimaryNoteView() + const { navigateToHashtag } = useSmartHashtagNavigation() + const { profile, pubkey, interestListEvent, updateInterestListEvent } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { subscribedTopics, subscribe, unsubscribe, changing } = useInterestList() + const [topicInput, setTopicInput] = useState('') + const [jsonOpen, setJsonOpen] = useState(false) + const [jsonPayload, setJsonPayload] = useState(null) + + const topicsSorted = useMemo( + () => [...subscribedTopics].sort((a, b) => a.localeCompare(b)), + [subscribedTopics] + ) + + const refreshFromRelays = useCallback(async () => { + if (!pubkey) return + const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays + }) + let latest = + (await fetchLatestReplaceableListEvent(pubkey, INTEREST_LIST_KIND, comprehensiveRelays)) ?? null + if (!latest) { + latest = (await client.fetchInterestListEvent(pubkey)) ?? null + } + if (latest) await updateInterestListEvent(latest) + }, [pubkey, favoriteRelays, blockedRelays, updateInterestListEvent]) + + const openJson = useCallback(() => { + setJsonPayload({ + interestListEvent: interestListEvent ?? null, + derivedTopics: topicsSorted, + note: 'Interest list is kind 10015; subscribed topics are stored as `t` tags.' + }) + setJsonOpen(true) + }, [interestListEvent, topicsSorted]) + + useEffect(() => { + if (!hideTitlebar) { + registerPrimaryPanelRefresh(null) + return + } + registerPrimaryPanelRefresh(() => { + void refreshFromRelays() + }) + return () => registerPrimaryPanelRefresh(null) + }, [hideTitlebar, registerPrimaryPanelRefresh, refreshFromRelays]) + + const onAddTopic = async (e: React.FormEvent) => { + e.preventDefault() + const raw = topicInput.trim().replace(/^#+/u, '') + const normalized = normalizeTopic(raw) + if (!normalized) { + toast.error(t('Interest topic invalid')) + return + } + await subscribe(normalized) + setTopicInput('') + } + + if (!profile || !pubkey) { + return + } + + return ( + + void refreshFromRelays()} /> + + + + + + openJson()}> + + {t('View JSON')} + + + + + ) + } + displayScrollToTopButton + > + setJsonOpen(false)} /> +
+

{t('Interests list section subtitle')}

+
void onAddTopic(ev)} className="flex flex-wrap items-center gap-2"> + setTopicInput(ev.target.value)} + placeholder={t('Interest topic placeholder')} + className="min-w-[12rem] flex-1" + disabled={changing} + aria-label={t('Interest topic placeholder')} + /> + +
+ {topicsSorted.length === 0 ? ( +

{t('No interest topics in list')}

+ ) : ( +
    + {topicsSorted.map((topic) => ( +
  • + + +
  • + ))} +
+ )} +
+
+ ) + } +) + +InterestListPage.displayName = 'InterestListPage' +export default InterestListPage diff --git a/src/pages/secondary/PersonalListsSettingsPage/index.tsx b/src/pages/secondary/PersonalListsSettingsPage/index.tsx index ce703544..8cf9fcd0 100644 --- a/src/pages/secondary/PersonalListsSettingsPage/index.tsx +++ b/src/pages/secondary/PersonalListsSettingsPage/index.tsx @@ -6,18 +6,26 @@ import { cn } from '@/lib/utils' import { useSmartBookmarkListNavigation, useSmartFollowingListNavigation, + useSmartInterestListNavigation, useSmartMuteListNavigation, useSmartPinListNavigation, useSmartSettingsNavigation } from '@/PageManager' -import { toBookmarksList, toFollowSetsSettings, toFollowingList, toMuteList, toPinsList } from '@/lib/link' +import { + toBookmarksList, + toFollowSetsSettings, + toFollowingList, + toInterestsList, + toMuteList, + toPinsList +} from '@/lib/link' import { useNostr } from '@/providers/NostrProvider' -import { Bookmark, ChevronRight, Pin, Users, VolumeX } from 'lucide-react' +import { Bookmark, ChevronRight, Hash, Pin, Users, VolumeX } from 'lucide-react' import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' /** - * Hub for Nostr “personal lists” (mute list, follows, NIP-51 bookmarks, pins) — not the same as NIP-B0 web bookmarks. + * Hub for Nostr “personal lists” (mute list, follows, NIP-51 bookmarks, pins, interest topics) — not the same as NIP-B0 web bookmarks. */ const PersonalListsSettingsPage = forwardRef( ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { @@ -29,6 +37,7 @@ const PersonalListsSettingsPage = forwardRef( const { navigateToFollowingList } = useSmartFollowingListNavigation() const { navigateToBookmarkList } = useSmartBookmarkListNavigation() const { navigateToPinList } = useSmartPinListNavigation() + const { navigateToInterestList } = useSmartInterestListNavigation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const [contentKey, setContentKey] = useState(0) const bump = useCallback(() => setContentKey((k) => k + 1), []) @@ -88,6 +97,15 @@ const PersonalListsSettingsPage = forwardRef( ) : null} + {pubkey ? ( + navigateToInterestList(toInterestsList())}> +
+ +
{t('Interests list')}
+
+ +
+ ) : null} navigateToSettings(toFollowSetsSettings())}>
@@ -108,6 +126,19 @@ const PersonalListsSettingsPage = forwardRef(

+

+ + + {t('Personal lists interests spell hint')}{' '} + + +

) diff --git a/src/routes.tsx b/src/routes.tsx index debaa72f..3c00def8 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -14,6 +14,7 @@ const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSett const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage')) const BookmarkListPageLazy = lazy(() => import('./pages/secondary/BookmarkListPage')) const PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage')) +const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage')) const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage')) const NotePageLazy = lazy(() => import('./pages/secondary/NotePage')) const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage')) @@ -89,6 +90,7 @@ const ROUTES = [ { path: '/mutes', element: SR(MuteListPageLazy) }, { path: '/bookmarks', element: SR(BookmarkListPageLazy) }, { path: '/pins', element: SR(PinListPageLazy) }, + { path: '/interests', element: SR(InterestListPageLazy) }, { path: '/follow-packs', element: SR(FollowPacksRedirectLazy) } ] diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts index 73ee2fe5..a2840ee4 100644 --- a/src/services/navigation.service.ts +++ b/src/services/navigation.service.ts @@ -44,6 +44,7 @@ export type ViewType = | 'mute' | 'bookmarks' | 'pins' + | 'interests' | 'others-relay-settings' | null @@ -292,6 +293,7 @@ export class NavigationService { if (viewType === 'mute') return 'Muted Users' if (viewType === 'bookmarks') return 'Bookmarks' if (viewType === 'pins') return 'Pinned notes' + if (viewType === 'interests') return 'Interests' if (viewType === 'others-relay-settings') return 'Relays and Storage Settings' return 'Page' }