Browse Source

add interest list

imwald
Silberengel 1 month ago
parent
commit
a8c5117cc7
  1. 28
      src/PageManager.tsx
  2. 1
      src/contexts/primary-note-view-context.tsx
  3. 13
      src/i18n/locales/en.ts
  4. 1
      src/lib/link.ts
  5. 32
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  6. 191
      src/pages/secondary/InterestListPage/index.tsx
  7. 37
      src/pages/secondary/PersonalListsSettingsPage/index.tsx
  8. 2
      src/routes.tsx
  9. 2
      src/services/navigation.service.ts

28
src/PageManager.tsx

@ -91,6 +91,7 @@ const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/Follow
const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage')) const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage'))
const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage')) const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage'))
const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage')) const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage'))
const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage'))
const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage')) const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage'))
const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage')) const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage'))
@ -777,6 +778,26 @@ export function useSmartPinListNavigation() {
return { navigateToPinList } 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(<PrimaryInterestListPageLazy index={0} hideTitlebar={true} />),
'interests'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToInterestList }
}
// Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop // Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartOthersRelaySettingsNavigation() { export function useSmartOthersRelaySettingsNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()
@ -1723,7 +1744,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
navigatePrimaryPage('settings') navigatePrimaryPage('settings')
return return
} }
if (primaryViewType === 'bookmarks' || primaryViewType === 'pins' || primaryViewType === 'mute') { if (
primaryViewType === 'bookmarks' ||
primaryViewType === 'pins' ||
primaryViewType === 'interests' ||
primaryViewType === 'mute'
) {
setPrimaryNoteView(null) setPrimaryNoteView(null)
return return
} }

1
src/contexts/primary-note-view-context.tsx

@ -11,6 +11,7 @@ export type TPrimaryOverlayViewType =
| 'mute' | 'mute'
| 'bookmarks' | 'bookmarks'
| 'pins' | 'pins'
| 'interests'
| 'others-relay-settings' | 'others-relay-settings'
export type PrimaryNoteViewContextValue = { export type PrimaryNoteViewContextValue = {

13
src/i18n/locales/en.ts

@ -1580,11 +1580,22 @@ export default {
'Follow sets': 'Follow sets', 'Follow sets': 'Follow sets',
'Personal Lists': 'Personal Lists', 'Personal Lists': 'Personal Lists',
'Personal lists hub intro': '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', 'Mute list': 'Mute list',
'Following list': 'Following list', 'Following list': 'Following list',
'Bookmarks list': 'Bookmarks list', 'Bookmarks list': 'Bookmarks list',
'Pinned notes list': 'Pinned notes 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': 'Personal lists bookmarks spell hint':
'For a note feed from NIP-51 bookmarks, use the', 'For a note feed from NIP-51 bookmarks, use the',
'Bookmarks spell': 'Bookmarks spell', 'Bookmarks spell': 'Bookmarks spell',

1
src/lib/link.ts

@ -82,6 +82,7 @@ export const toMuteList = () => '/mutes'
export const toBookmarksList = () => '/bookmarks' export const toBookmarksList = () => '/bookmarks'
export const toPinsList = () => '/pins' export const toPinsList = () => '/pins'
export const toInterestsList = () => '/interests'
export const toSpells = () => '/spells' export const toSpells = () => '/spells'
export const toChachiChat = (relay: string, d: string) => { export const toChachiChat = (relay: string, d: string) => {

32
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 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 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 * 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 * ({@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). * 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( export function buildInterestsSubRequests(
relayUrls: string[], relayUrls: string[],
@ -167,9 +183,19 @@ export function buildInterestsSubRequests(
kindsList: number[] = DEFAULT_FEED_SHOW_KINDS kindsList: number[] = DEFAULT_FEED_SHOW_KINDS
): TFeedSubRequest[] { ): TFeedSubRequest[] {
if (!relayUrls.length || !rawTopics.length || !kindsList.length) return [] 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)) new Set(rawTopics.map((t) => normalizeTopic(t)).filter((t) => t.length > 0))
).slice(0, INTERESTS_MAX_TOPICS) ).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 [] if (!topics.length) return []
return [ return [
{ {

191
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<unknown>(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 <NotFoundPage />
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={
hideTitlebar
? undefined
: t("username's interest topics", {
username: profile.username,
defaultValue: `${profile.username}'s interest topics`
})
}
hideBackButton={hideTitlebar}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={() => void refreshFromRelays()} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openJson()}>
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<div className="min-w-0 space-y-3 px-3 pb-4 pt-2">
<p className="text-sm text-muted-foreground">{t('Interests list section subtitle')}</p>
<form onSubmit={(ev) => void onAddTopic(ev)} className="flex flex-wrap items-center gap-2">
<Input
value={topicInput}
onChange={(ev) => setTopicInput(ev.target.value)}
placeholder={t('Interest topic placeholder')}
className="min-w-[12rem] flex-1"
disabled={changing}
aria-label={t('Interest topic placeholder')}
/>
<Button type="submit" disabled={changing || !topicInput.trim()}>
{t('Interest list add topic')}
</Button>
</form>
{topicsSorted.length === 0 ? (
<p className="pt-2 text-center text-sm text-muted-foreground">{t('No interest topics in list')}</p>
) : (
<ul className="space-y-1">
{topicsSorted.map((topic) => (
<li
key={topic}
className={cn(
'flex min-h-[48px] items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2'
)}
>
<button
type="button"
className="min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline"
onClick={() => navigateToHashtag(toNoteList({ hashtag: topic }))}
>
#{topic}
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
disabled={changing}
title={t('Remove from interest list')}
aria-label={t('Remove from interest list')}
onClick={() => void unsubscribe(topic)}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
)}
</div>
</SecondaryPageLayout>
)
}
)
InterestListPage.displayName = 'InterestListPage'
export default InterestListPage

37
src/pages/secondary/PersonalListsSettingsPage/index.tsx

@ -6,18 +6,26 @@ import { cn } from '@/lib/utils'
import { import {
useSmartBookmarkListNavigation, useSmartBookmarkListNavigation,
useSmartFollowingListNavigation, useSmartFollowingListNavigation,
useSmartInterestListNavigation,
useSmartMuteListNavigation, useSmartMuteListNavigation,
useSmartPinListNavigation, useSmartPinListNavigation,
useSmartSettingsNavigation useSmartSettingsNavigation
} from '@/PageManager' } 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 { 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 { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' 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( const PersonalListsSettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
@ -29,6 +37,7 @@ const PersonalListsSettingsPage = forwardRef(
const { navigateToFollowingList } = useSmartFollowingListNavigation() const { navigateToFollowingList } = useSmartFollowingListNavigation()
const { navigateToBookmarkList } = useSmartBookmarkListNavigation() const { navigateToBookmarkList } = useSmartBookmarkListNavigation()
const { navigateToPinList } = useSmartPinListNavigation() const { navigateToPinList } = useSmartPinListNavigation()
const { navigateToInterestList } = useSmartInterestListNavigation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), []) const bump = useCallback(() => setContentKey((k) => k + 1), [])
@ -88,6 +97,15 @@ const PersonalListsSettingsPage = forwardRef(
<ChevronRight /> <ChevronRight />
</SettingRow> </SettingRow>
) : null} ) : null}
{pubkey ? (
<SettingRow className="clickable" onClick={() => navigateToInterestList(toInterestsList())}>
<div className="flex items-center gap-3">
<Hash />
<div>{t('Interests list')}</div>
</div>
<ChevronRight />
</SettingRow>
) : null}
<SettingRow className="clickable" onClick={() => navigateToSettings(toFollowSetsSettings())}> <SettingRow className="clickable" onClick={() => navigateToSettings(toFollowSetsSettings())}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Users /> <Users />
@ -108,6 +126,19 @@ const PersonalListsSettingsPage = forwardRef(
</button> </button>
</span> </span>
</p> </p>
<p className="flex min-h-[52px] items-start gap-3 rounded-lg px-4 py-2 text-sm text-muted-foreground">
<Hash className="mt-0.5 size-4 shrink-0 opacity-80" />
<span>
{t('Personal lists interests spell hint')}{' '}
<button
type="button"
className="text-primary underline-offset-4 hover:underline"
onClick={() => navigatePrimary('spells', { spell: 'interests' })}
>
{t('Interests spell')}
</button>
</span>
</p>
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

2
src/routes.tsx

@ -14,6 +14,7 @@ const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSett
const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage')) const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage'))
const BookmarkListPageLazy = lazy(() => import('./pages/secondary/BookmarkListPage')) const BookmarkListPageLazy = lazy(() => import('./pages/secondary/BookmarkListPage'))
const PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage')) const PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage'))
const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage'))
const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage')) const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage'))
const NotePageLazy = lazy(() => import('./pages/secondary/NotePage')) const NotePageLazy = lazy(() => import('./pages/secondary/NotePage'))
const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage')) const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage'))
@ -89,6 +90,7 @@ const ROUTES = [
{ path: '/mutes', element: SR(MuteListPageLazy) }, { path: '/mutes', element: SR(MuteListPageLazy) },
{ path: '/bookmarks', element: SR(BookmarkListPageLazy) }, { path: '/bookmarks', element: SR(BookmarkListPageLazy) },
{ path: '/pins', element: SR(PinListPageLazy) }, { path: '/pins', element: SR(PinListPageLazy) },
{ path: '/interests', element: SR(InterestListPageLazy) },
{ path: '/follow-packs', element: SR(FollowPacksRedirectLazy) } { path: '/follow-packs', element: SR(FollowPacksRedirectLazy) }
] ]

2
src/services/navigation.service.ts

@ -44,6 +44,7 @@ export type ViewType =
| 'mute' | 'mute'
| 'bookmarks' | 'bookmarks'
| 'pins' | 'pins'
| 'interests'
| 'others-relay-settings' | 'others-relay-settings'
| null | null
@ -292,6 +293,7 @@ export class NavigationService {
if (viewType === 'mute') return 'Muted Users' if (viewType === 'mute') return 'Muted Users'
if (viewType === 'bookmarks') return 'Bookmarks' if (viewType === 'bookmarks') return 'Bookmarks'
if (viewType === 'pins') return 'Pinned notes' if (viewType === 'pins') return 'Pinned notes'
if (viewType === 'interests') return 'Interests'
if (viewType === 'others-relay-settings') return 'Relays and Storage Settings' if (viewType === 'others-relay-settings') return 'Relays and Storage Settings'
return 'Page' return 'Page'
} }

Loading…
Cancel
Save